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/desktop-macos-preview-publish.yml b/.github/workflows/desktop-macos-preview-publish.yml index bd6f59bc0f0bc..6e8805b7f790b 100644 --- a/.github/workflows/desktop-macos-preview-publish.yml +++ b/.github/workflows/desktop-macos-preview-publish.yml @@ -77,16 +77,19 @@ jobs: return; } - // workflow_run.pull_requests is empty for fork PRs, so resolve the - // PR from the built commit instead and require exactly one open PR - // from the same head repository and branch. The build baked its + // 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 + // and require exactly one at the built commit. The build baked its // PR number into the version, so two candidates would mean the // asset name could belong to either. - const associated = await github.paginate( - github.rest.repos.listPullRequestsAssociatedWithCommit, - { owner, repo, commit_sha: run.head_sha, per_page: 100 }, - ); - const matching = associated.filter( + 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 && diff --git a/.github/workflows/mobile-eas-production.yml b/.github/workflows/mobile-eas-production.yml index 4dd434a956e25..b2c03c04ab11c 100644 --- a/.github/workflows/mobile-eas-production.yml +++ b/.github/workflows/mobile-eas-production.yml @@ -160,7 +160,20 @@ jobs: working-directory: apps/mobile env: EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} - run: eas env:pull production --non-interactive + run: | + eas env:pull production --non-interactive + # EAS commands skip dotenv, and the Android fingerprint hashes this + # file. Without it every merge misses the build's hash and rebuilds. + node --input-type=module <<'NODE' + import { appendFileSync, readFileSync } from "node:fs"; + import { parseEnv } from "node:util"; + const env = parseEnv(readFileSync(".env.local", "utf8")); + const googleServicesFile = env.T3CODE_ANDROID_GOOGLE_SERVICES_FILE; + if (!googleServicesFile) { + throw new Error("T3CODE_ANDROID_GOOGLE_SERVICES_FILE is missing from the production environment"); + } + appendFileSync(process.env.GITHUB_ENV, `T3CODE_ANDROID_GOOGLE_SERVICES_FILE=${googleServicesFile}\n`); + NODE - name: Apply manual version override if: steps.version_app_token.outcome == 'success' diff --git a/.github/workflows/mobile-fingerprint-check.yml b/.github/workflows/mobile-fingerprint-check.yml index b8b872f019f6e..9fabb3a5f2ad2 100644 --- a/.github/workflows/mobile-fingerprint-check.yml +++ b/.github/workflows/mobile-fingerprint-check.yml @@ -2,7 +2,8 @@ name: Mobile Fingerprint Check # Detects whether a PR changes the native fingerprint — i.e. whether merging # it would leave main un-OTA-able until a new store build ships. Native-change -# PRs get the "📱 Native Change" label so they can be held and merged as a +# PRs get the "📱 Native Change" label (applied by mobile-fingerprint-label.yml, +# which runs from main so fork PRs get it too) so they can be held and merged as a # batch right before the next store submission, keeping main OTA-able for # everything else in between. (Once one native PR merges, every later merge # inherits the drifted fingerprint and loses OTA reach too — that is why the @@ -27,8 +28,6 @@ jobs: runs-on: blacksmith-8vcpu-ubuntu-2404 permissions: contents: read - issues: write - pull-requests: write env: APP_VARIANT: production NODE_OPTIONS: --max-old-space-size=8192 @@ -127,94 +126,19 @@ jobs: } >> "$GITHUB_STEP_SUMMARY" echo "changed_platforms=${changed# }" >> "$GITHUB_OUTPUT" - - name: Sync native change label - # Fork PRs get a read-only token under pull_request; the check stays - # advisory there (summary only). This workflow must not move to - # pull_request_target — it installs and runs PR code. - if: github.event.pull_request.head.repo.full_name == github.repository - uses: actions/github-script@v8 + # mobile-fingerprint-label.yml reads this from main with a write token + # and syncs the label; this job's token is read-only for fork PRs. + - name: Record changed platforms env: CHANGED_PLATFORMS: ${{ steps.compare.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 = context.payload.pull_request.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; - } - } - } + run: | + mkdir -p "$RUNNER_TEMP/fingerprint-result" + printf '%s\n' "$CHANGED_PLATFORMS" > "$RUNNER_TEMP/fingerprint-result/changed-platforms" - core.info( - `PR #${issueNumber}: native fingerprint ${nativeChanged ? `changed (${process.env.CHANGED_PLATFORMS})` : "unchanged"}`, - ); + - name: Upload changed platforms + uses: actions/upload-artifact@v7 + with: + name: fingerprint-result + path: ${{ runner.temp }}/fingerprint-result + if-no-files-found: error + retention-days: 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/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index 359070e5482fb..ba40c4fb89cba 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -163,6 +163,29 @@ jobs: env: pnpm_config_cache_dir: ${{ runner.temp }}/pnpm-metadata + # The CLI archive and the staged app install without a lockfile, so they + # ask the registry about every package and fetch platform packages the + # workspace store lacks. They share this registry metadata and store. A + # new week starts cold, which bounds how stale cached ranges can get. + - name: Resolve staged install cache + id: stage_pnpm + shell: bash + run: | + echo "dir=$RUNNER_TEMP/desktop-stage-pnpm" >> "$GITHUB_OUTPUT" + echo "prefix=desktop-stage-pnpm-v2-${{ inputs.platform }}-${{ inputs.arch }}-$(date -u +%G-%V)-" >> "$GITHUB_OUTPUT" + + - name: Restore staged install cache + id: stage_pnpm_cache + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 + with: + path: ${{ steps.stage_pnpm.outputs.dir }} + key: ${{ steps.stage_pnpm.outputs.prefix }}${{ github.run_id }}-${{ github.run_attempt }} + restore-keys: ${{ steps.stage_pnpm.outputs.prefix }} + + - name: Mark staged install cache + shell: bash + run: mkdir -p "${{ steps.stage_pnpm.outputs.dir }}" && touch "$RUNNER_TEMP/desktop-stage-pnpm.marker" + - name: Cache resource monitor id: resource_monitor_cache uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 @@ -371,6 +394,9 @@ jobs: if: inputs.cli_archive shell: bash env: + pnpm_config_cache_dir: ${{ steps.stage_pnpm.outputs.dir }}/cache + pnpm_config_store_dir: ${{ steps.stage_pnpm.outputs.dir }}/store + pnpm_config_prefer_offline: "true" APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} @@ -490,7 +516,10 @@ jobs: - name: Build desktop artifact shell: bash env: - pnpm_config_cache_dir: ${{ runner.temp }}/pnpm-metadata + # pnpm only goes to the registry for packages or versions the cache lacks. + pnpm_config_cache_dir: ${{ steps.stage_pnpm.outputs.dir }}/cache + pnpm_config_store_dir: ${{ steps.stage_pnpm.outputs.dir }}/store + pnpm_config_prefer_offline: "true" T3CODE_DESKTOP_REUSE_RESOURCE_MONITOR: true T3CODE_DESKTOP_REUSE_LINUX_CAPTURE_HELPERS: ${{ steps.capture_helper_cache.outputs.cache-hit == 'true' }} CSC_LINK: ${{ secrets.CSC_LINK }} @@ -575,6 +604,23 @@ jobs: vp run dist:desktop:artifact "${args[@]}" + # Warm installs only touch the store index, so new content means a fetch. + - name: Check staged install cache for new content + id: stage_pnpm_changed + shell: bash + run: | + dir="${{ steps.stage_pnpm.outputs.dir }}" + new=$(find "$dir/cache/v12" "$dir/store/v11/files" -type f -newer "$RUNNER_TEMP/desktop-stage-pnpm.marker" 2>/dev/null | wc -l | tr -d ' ') + echo "Staged installs added $new cached files." + echo "changed=$([[ "$new" -gt 0 ]] && echo true || echo false)" >> "$GITHUB_OUTPUT" + + - name: Save staged install cache + if: steps.stage_pnpm_changed.outputs.changed == 'true' + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 + with: + path: ${{ steps.stage_pnpm.outputs.dir }} + key: ${{ steps.stage_pnpm_cache.outputs.cache-primary-key }} + # A build can package fine and still not open (a missing module in the # backend or the bundle). This launches the app from the shipped # AppImage or DMG. diff --git a/AGENTS.md b/AGENTS.md index bda74a8ac9eff..d9699e8f1a3ff 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 @@ -219,5 +219,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 53ec4a5eba7eb..c70394bea03a1 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,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..7f768c1b44d75 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.4", "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/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index ac4c6e61378e3..c80498c14c1fb 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -34,7 +34,6 @@ const clientSettings: ClientSettings = { confirmThreadDelete: false, confirmThreadUnpin: false, contextWindowMeterEnabled: false, - composerCollapseOnScroll: true, diffFilesCollapsed: true, diffIgnoreWhitespace: true, diffLayout: "stacked", @@ -64,6 +63,8 @@ const clientSettings: ClientSettings = { sidebarProjectSortOrder: "manual", sidebarThreadSortOrder: "created_at", boringSidebarEnabled: false, + boringSidebarMultilineRows: false, + threadDetailsUsageEnabled: false, legacySidebarEnabled: false, sidebarWorkingShelfEnabled: false, loadBalancingEnabled: false, 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/pages/index.astro b/apps/marketing/src/pages/index.astro index a4767e7bb4bca..883afb9752468 100644 --- a/apps/marketing/src/pages/index.astro +++ b/apps/marketing/src/pages/index.astro @@ -98,7 +98,7 @@ const mobileEndorsementRows = [ src={desktopScreenshot.src} srcset={`${screenshot.srcSet.attribute}, ${desktopScreenshot.src} ${desktopScreenshot.width}w`} sizes="(max-width: 960px) min(864px, calc(100vw - 20px)), min(1180px, calc(100vw - 60px))" - alt="T3 Code with a settled thread beside its pull request diff" + alt="T3 Code running Claude, Codex, Muse, and Gemini subagents in one thread" width={desktopScreenshot.width} height={desktopScreenshot.height} loading="eager" 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/metro.config.js b/apps/mobile/metro.config.js index afbef3362d6d5..6371da34fd190 100644 --- a/apps/mobile/metro.config.js +++ b/apps/mobile/metro.config.js @@ -56,10 +56,6 @@ async function generateMobileThirdPartyLicenses() { const manifest = await generateThirdPartyLicenseManifest({ configFile: path.join(workspaceRoot, "third-party-licenses.config.json"), packageManifests: [{ bundle: "mobile", path: path.join(__dirname, "package.json") }], - allowMissingGeneratedNotices: - process.env.NODE_ENV !== "production" && - process.env.EAS_BUILD !== "true" && - process.env.T3CODE_LICENSES_STRICT !== "1", }); await Promise.all([ 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" ? ( - - diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index c257d0ea52d34..ddce50db3cfe3 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -12,6 +12,7 @@ import { CheckIcon, ChevronDownIcon, Columns2Icon, + FlaskConicalOffIcon, FolderTreeIcon, PilcrowIcon, Rows3Icon, @@ -37,6 +38,8 @@ import { useDiffPanelStore, } from "../diffPanelStore"; import { useLocalStorage } from "../hooks/useLocalStorage"; +import { isTestChangedFile } from "@t3tools/shared/changedFileKind"; +import { useHideTestFiles } from "./diffs/useHideTestFiles"; import { useRenderedAppearance } from "../hooks/useTheme"; import { useStableCallback } from "../hooks/useStableCallback"; import { @@ -218,6 +221,7 @@ export default function DiffPanel({ false, Schema.Boolean, ); + const [hideTestFiles, setHideTestFiles] = useHideTestFiles(); const [baseRefQuery, setBaseRefQuery] = useState(""); const [codeViewRevision, setCodeViewRevision] = useState(0); @@ -539,6 +543,16 @@ export default function DiffPanel({ const isRefreshingDiff = branchDiffPreview.isPending || areFilePatchesPending; const renderableFileEntries = renderableFiles.map(getCachedFileEntry); const diffFileKeys = renderableFileEntries.map((file) => file.fileKey); + const testFileCount = renderableFiles.filter((file) => + isTestChangedFile(resolveFileDiffPath(file)), + ).length; + // Hiding is display-only: lazy loading indexes into the full list. A requested file stays visible. + const isFileShown = (file: FileDiffMetadata) => { + const path = resolveFileDiffPath(file); + return !hideTestFiles || path === selectedFilePath || !isTestChangedFile(path); + }; + const shownFileEntries = renderableFileEntries.filter(({ fileDiff }) => isFileShown(fileDiff)); + const shownFileKeys = shownFileEntries.map((file) => file.fileKey); const defaultCollapsedDiffFileKeys = settings.diffFilesCollapsed ? new Set(diffFileKeys) : EMPTY_COLLAPSED_DIFF_FILE_KEYS; @@ -561,7 +575,7 @@ export default function DiffPanel({ count={renderableFiles.length - settledFileCount} /> ) : null; - const codeViewFiles = renderableFileEntries + const codeViewFiles = shownFileEntries .filter( ({ fileDiff }) => !lazySource || !readyFilePaths || readyFilePaths.has(resolveFileDiffPath(fileDiff)), @@ -574,10 +588,12 @@ export default function DiffPanel({ collapsed: collapsedDiffFileKeys.has(fileKey) || fileDiff.cacheKey?.endsWith(":pending") === true, })); - const pendingFiles = renderableFiles.filter((file) => file.cacheKey?.endsWith(":pending")); - const allDiffFilesCollapsed = areAllDiffFilesCollapsed(diffFileKeys, collapsedDiffFileKeys); + const pendingFiles = renderableFiles.filter( + (file) => file.cacheKey?.endsWith(":pending") && isFileShown(file), + ); + const allDiffFilesCollapsed = areAllDiffFilesCollapsed(shownFileKeys, collapsedDiffFileKeys); const diffLineStat = sourceLineStat ?? getDiffLineStat(renderableFiles); - const fileTreeEntries = diffFileTreeEntries(renderableFiles); + const fileTreeEntries = diffFileTreeEntries(renderableFiles.filter(isFileShown)); const codeViewReveal = useCodeViewFileReveal(); const revealDiffFile = (filePath: string) => { const index = renderableFileEntries.findIndex( @@ -661,7 +677,7 @@ export default function DiffPanel({ const toggleDiffFileCollapse = () => { setCodeViewRevision((current) => current + 1); - updateCollapsedDiffFiles((current) => toggleAllDiffFiles(diffFileKeys, current)); + updateCollapsedDiffFiles((current) => toggleAllDiffFiles(shownFileKeys, current)); }; const selectTurn = (runId: RunId) => { @@ -991,6 +1007,28 @@ export default function DiffPanel({ {diffIgnoreWhitespace ? "Show whitespace changes" : "Hide whitespace changes"} + {testFileCount > 0 && ( + + setHideTestFiles(Boolean(pressed))} + /> + } + > + + + + {hideTestFiles + ? `Show ${testFileCount} hidden test ${testFileCount === 1 ? "file" : "files"}` + : "Hide test files"} + + + )} {diffFileKeys.length > 0 && ( { }); describe("groupSidebarThreadsByProject", () => { - it("puts each thread in its project's folder and keeps empty projects", () => { + it("puts each thread in its project's folder and drops empty projects unless kept", () => { const remoteEnvironmentId = EnvironmentId.make("environment-remote"); const t3code = ProjectId.make("t3code"); const t3codeRemote = ProjectId.make("t3code-remote"); @@ -3174,21 +3174,22 @@ describe("groupSidebarThreadsByProject", () => { }, ]; - const folders = groupSidebarThreadsByProject(groups, [ + const threads = [ thread("a", localEnvironmentId, t3code), thread("orphan", localEnvironmentId, ProjectId.make("deleted")), thread("b", remoteEnvironmentId, t3codeRemote), - ]); - - expect( - folders.map((folder) => ({ + ]; + const summarize = (keepEmpty: boolean) => + groupSidebarThreadsByProject(groups, threads, keepEmpty).map((folder) => ({ projectKey: folder.group.projectKey, threads: folder.threads.map((entry) => entry.id), - })), - ).toEqual([ + })); + + expect(summarize(true)).toEqual([ { projectKey: "t3code", threads: ["a", "b"] }, { projectKey: "fleet", threads: [] }, ]); + expect(summarize(false)).toEqual([{ projectKey: "t3code", threads: ["a", "b"] }]); }); }); diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index fb92c69eb15c1..316a81e5fb6b8 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -63,7 +63,7 @@ export function shouldNavigateAfterThreadPark(input: { } const THREAD_SELECTION_SAFE_SELECTOR = "[data-thread-item], [data-thread-selection-safe]"; -export const SIDEBAR_THREAD_PREWARM_LIMIT = 3; +const SIDEBAR_THREAD_PREWARM_LIMIT = 3; export const THREAD_JUMP_HINT_SHOW_DELAY_MS = 200; export function resolveSidebarRowAccessibility(input: { @@ -1352,8 +1352,8 @@ export function sidebarFolderExpansionKeys(group: { } /** - * Boring sidebar folders. Folders follow `groups` order and include empty - * projects so each one can start a thread. Threads keep their input order. + * Boring sidebar folders. Folders follow `groups` order and threads keep their + * input order. A project with no threads gets a folder only with `keepEmpty`. * A thread whose project is in no group has no folder and is left out. */ export function groupSidebarThreadsByProject< @@ -1367,6 +1367,7 @@ export function groupSidebarThreadsByProject< >( groups: readonly TGroup[], threads: readonly TThread[], + keepEmpty: boolean, ): Array<{ group: TGroup; threads: TThread[] }> { const folders = groups.map((group) => ({ group, threads: [] as TThread[] })); const folderByProject = new Map(); @@ -1378,7 +1379,7 @@ export function groupSidebarThreadsByProject< for (const thread of threads) { folderByProject.get(`${thread.environmentId}:${thread.projectId}`)?.threads.push(thread); } - return folders; + return keepEmpty ? folders : folders.filter((folder) => folder.threads.length > 0); } export type SidebarV2TopStatusKind = @@ -1508,40 +1509,6 @@ export function pullRequestMatchesSearchQuery( return normalizedQuery.split(" ").every((token) => haystack.includes(token)); } -export function filterSidebarProjectScopeItems(input: { - items: readonly TItem[]; - query: string; - matches: (item: TItem, query: string) => boolean; -}): readonly TItem[] { - const query = input.query.trim(); - if (query.length === 0) return input.items; - return input.items.filter((item) => item.value !== "all" && input.matches(item, query)); -} - -interface SidebarProjectScopeMenuState { - readonly open: boolean; - readonly query: string; -} - -type SidebarProjectScopeMenuAction = - | { readonly type: "query-changed"; readonly query: string } - | { readonly type: "open-changed"; readonly open: boolean } - | { readonly type: "project-settings-opened" }; - -export function reduceSidebarProjectScopeMenuState( - state: SidebarProjectScopeMenuState, - action: SidebarProjectScopeMenuAction, -): SidebarProjectScopeMenuState { - switch (action.type) { - case "query-changed": - return { ...state, query: action.query }; - case "open-changed": - return { open: action.open, query: "" }; - case "project-settings-opened": - return { open: false, query: "" }; - } -} - /** The timestamp a working thread's elapsed label counts from: when its current work started (request time until adoption). Background wakes do not reset it. Malformed timestamps fall through to the next candidate. */ diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index a70cd68dd67a1..8372951b0f88a 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -136,6 +136,7 @@ import { useThreadSelectionStore, } from "../threadSelectionStore"; import { + canTurnThreadIntoProject, readThreadSubscriptionLimitGuard, useAcknowledgeThreadWoke, useThreadActions, @@ -420,6 +421,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve setThreadHidden, setThreadGroup, confirmAndMoveThreadToProject, + turnThreadIntoProject, reorderPinnedThread, reorderActiveThread, markThreadUnread, @@ -490,6 +492,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve ); }, }); + const folderRowsMultiline = useClientSettings((settings) => settings.boringSidebarMultilineRows); const newThreadContext = useHandleNewThread(); const { handleNewThread } = newThreadContext; const openAddProjectCommandPalette = () => openCommandPalette({ open: "add-project" }); @@ -673,6 +676,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve scopedProjectKeys, optimisticDrop, workingShelfEnabled, + sortActiveBySend: boringSidebarEnabled, clockKey: `${nowMinute}:${snoozeWakeTick}:${inboxReturnsVersion}`, }); const projectExpandedById = useUiStateStore((store) => store.projectExpandedById); @@ -685,7 +689,11 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve const projectFolders = folderGroups === null ? null - : groupSidebarThreadsByProject(folderGroups, activeThreads).map((folder) => { + : groupSidebarThreadsByProject( + folderGroups, + activeThreads, + scopedProjectGroups.length > 0, + ).map((folder) => { const collapsed = !resolveProjectExpanded( projectExpandedById, sidebarFolderExpansionKeys(folder.group), @@ -2587,6 +2595,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve canShareWithGuest: appAtomRegistry.get( canShareWithGuestAtom(threadRef.environmentId), ), + canTurnIntoProject: canTurnThreadIntoProject(threadRef), canLinkGitHubIssue: isGitHubProject( readProject(scopeProjectRef(thread.environmentId, thread.projectId)), ), @@ -2736,6 +2745,26 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve case "unhide": attemptSetHidden(rowRef, clicked.value === "hide"); 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)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not turn thread into a project", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + return; + } case "auto-settle:enabled": case "auto-settle:disabled": { const result = await setThreadAutoSettle( @@ -3218,7 +3247,9 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve scopeThreadRef(thread.environmentId, thread.id), ); const rowVariant = folderRow - ? "folder" + ? folderRowsMultiline + ? "folder-multiline" + : "folder" : sidebarThreadView(section, thread.groupName, threadGroups, threadViews); const isCard = rowVariant === "card"; const rowTabs = tabsByRowKey.get(threadKey); @@ -3573,7 +3604,6 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve className="mt-auto" label="Working" count={workingThreads.length} - hostsUndoNotice toggle={{ expanded: workingShelfExpanded, onToggle: toggleWorkingShelf, @@ -3698,9 +3728,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve ) : null} - + ); } diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index e0d2e4e60563e..92e931445a720 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -97,7 +97,11 @@ import { useThreadTabContextRecords } from "~/threadTabContextStore"; import { useComposerTypingGuard } from "./useComposerTypingGuard"; import { insertAttachmentReferencesForDraft } from "./composerAttachmentReferences"; import { DESKTOP_PASTE_AS_TEXT_EVENT } from "../../lib/desktopPasteAsText"; -import { runtimeModeConfig, runtimeModeOptions as runtimeModes } from "./runtimeModeConfig"; +import { + resolveCompatibleRuntimeMode, + runtimeModeConfig, + runtimeModeOptions as runtimeModes, +} from "./runtimeModeConfig"; import { isLocalEnvironmentDisabled } from "../../localEnvironment"; import { usePrimaryEnvironmentId } from "../../state/environments"; import { useRightPanelStore } from "~/rightPanelStore"; @@ -128,7 +132,7 @@ import { useRef, useState, } from "react"; -import { createPortal, flushSync } from "react-dom"; +import { flushSync } from "react-dom"; import { clampCollapsedComposerCursor, type ComposerTrigger, @@ -158,13 +162,13 @@ import { import { isInsideCollapsedComposerControls, isInsideRestingComposerControlScope, + markOpenToPermissions, } from "./composerEventScope"; import { ComposerStashBadge } from "./ComposerStashBadge"; import { ComposerStashMenu } from "./ComposerStashMenu"; import { useComposerTriggerState } from "./useComposerTriggerState"; import { useComposerMenuItems } from "./useComposerMenuItems"; -import { useComposerFocusState } from "./useComposerFocusState"; import { useComposerMultilinePrompt } from "./useComposerMultilinePrompt"; import { reconcileAttachmentContextReferences, @@ -199,7 +203,6 @@ import { threadContextRecord } from "@t3tools/shared/composerContextReferences"; import { shouldUseCompactComposerPrimaryActions, shouldUseCompactComposerFooter, - shouldUseRestingComposerLayout, } from "../composerFooterLayout"; import { @@ -275,20 +278,9 @@ import { import { ComposerPromptLengthValidation } from "./ComposerPromptLengthValidation"; import { pendingDraftWork } from "./pendingDraftWork"; import { importComposerThreadAttachment } from "./composerThreadImport"; -import { isTimelineScrollTarget } from "./timelineScrollTarget"; -import { - createComposerScrollGestureState, - recordComposerScrollGestureEvent, - shouldCollapseComposerForScrollKey, - resetComposerScrollGesture, - suppressActiveComposerScrollGesture, -} from "./composerScrollGesture"; import { observeResize } from "~/lib/observeResize"; const isEnvironmentRequestInvalidError = Schema.is(EnvironmentRequestInvalidError); - -const COMPOSER_SCROLL_COLLAPSE_THRESHOLD_PX = 24; -const COMPOSER_SCROLL_GESTURE_RESET_MS = 120; import { toastManager } from "../ui/toast"; import { proposedPlanTitle } from "@t3tools/shared/proposedPlanText"; @@ -303,7 +295,7 @@ import { type ThreadShell, } from "../../types"; import { type ComposerPromptHistoryPosition } from "./composerPromptHistory"; -import type { PendingUserInputDraftAnswer } from "../../pendingUserInput"; +import type { PendingUserInputDraftAnswer } from "@t3tools/client-runtime/state/pending-user-input"; import type { LatestProposedPlanState, PendingApproval, @@ -351,8 +343,6 @@ const runtimeModeOptions = runtimeModes.map((mode) => ({ mode, ...runtimeModeCon export interface ChatComposerHandle { focusAtEnd: () => void; focusAt: (cursor: number) => void; - restoreAfterTimelineReachedEnd: () => void; - collapseForTimelineScrollKey: (key: string) => void; /** Resolves once the files are in the draft, compressed and ready to send. */ addDroppedFiles: (files: File[]) => Promise; addDroppedFolders: (folders: File[]) => void; @@ -497,6 +487,7 @@ export interface ChatComposerProps { threadSyncPhase: ThreadSyncPhase | null; runtimeMode: RuntimeMode; + defaultRuntimeMode?: RuntimeMode; interactionMode: ProviderInteractionMode; lockedProvider: ProviderDriverKind | null; @@ -523,14 +514,9 @@ export interface ChatComposerProps { pullRequestProjectId: ProjectId | null; pullRequestRepository: string | null; threadPullRequests?: ReadonlyArray | undefined; - restingControlsHost: HTMLDivElement | null; - restingControlsHaveLeadingContext: boolean; - onRestingControlsVisibilityChange: (visible: boolean) => void; - getTimelineScrollableNode: () => HTMLElement | null; - isTimelineAtLogicalEnd: () => boolean; - timelineOverflows: boolean; onComposerOverlayHeightChange: (height: number) => void; - onRestingChange: (resting: boolean) => void; + /** The new-thread strip names the permissions, so the controls row hides its chip. */ + permissionsShownInStrip: boolean; promptRef: React.RefObject; composerImagesRef: React.RefObject; @@ -668,14 +654,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) pullRequestProjectId, pullRequestRepository, threadPullRequests, - restingControlsHost, - restingControlsHaveLeadingContext, - onRestingControlsVisibilityChange, - getTimelineScrollableNode, - isTimelineAtLogicalEnd, - timelineOverflows, onComposerOverlayHeightChange, - onRestingChange, promptRef, composerRef, composerImagesRef, @@ -824,7 +803,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const { pendingSnapShotAnimations, pendingSnapShotIds, - pendingSnapShotIdSet, uncommittedSnapShotIds, standaloneComposerImages, } = useComposerImageGroups(attachmentDraftTarget, composerImages, composerPreviewAnnotations); @@ -1042,16 +1020,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) : undefined; const resolvedCompactDisabledReason = compactDisabledReason ?? (noProviderAvailable ? "Compacting is unavailable right now" : null); - const supportedRuntimeModes = selectedProviderEntry?.snapshot.supportedRuntimeModes; - const compatibleRuntimeModeOptions = - supportedRuntimeModes && supportedRuntimeModes.length > 0 - ? runtimeModeOptions.filter((option) => supportedRuntimeModes.includes(option.mode)) - : runtimeModeOptions; - const compatibleRuntimeMode = compatibleRuntimeModeOptions.some( - (option) => option.mode === runtimeMode, - ) - ? runtimeMode - : (compatibleRuntimeModeOptions[0]?.mode ?? runtimeMode); + const { modes: compatibleRuntimeModes, mode: compatibleRuntimeMode } = + resolveCompatibleRuntimeMode( + runtimeMode, + selectedProviderEntry?.snapshot.supportedRuntimeModes, + ); + const compatibleRuntimeModeOptions = runtimeModeOptions.filter((option) => + compatibleRuntimeModes.includes(option.mode), + ); const providerSendBlockReason = getAntigravitySendBlockReason( selectedProviderEntry?.snapshot, @@ -1148,18 +1124,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) null, ); const [isDragOverComposer, setIsDragOverComposer] = useState(false); - const [isComposerFooterCompact, setIsComposerFooterCompact] = useState(false); const [isComposerPrimaryActionsCompact, setIsComposerPrimaryActionsCompact] = useState(false); const [isComposerModelPickerOpen, setIsComposerModelPickerOpen] = useState(false); const isMobileViewport = useMediaQuery("max-sm"); const isSoftwareKeyboardOpen = useSoftwareKeyboardOpen(); - const { - isComposerFocused, - setIsComposerFocused, - isComposerScrollCollapsed, - setIsComposerScrollCollapsed, - restoreAfterTimelineReachedEnd, - } = useComposerFocusState(); + const [isComposerFocused, setIsComposerFocused] = useState(false); const [composerSubmissionError, setComposerSubmissionError] = useState(null); const [providerInputSubmissionError, setProviderInputSubmissionError] = useState( null, @@ -1189,10 +1158,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const mobileComposerExpandFrameRef = useRef(null); const mobileComposerExpandReleaseFrameRef = useRef(null); const mobileComposerExpandInFlightRef = useRef(false); - const composerScrollCollapseTimeoutRef = useRef(null); - const composerScrollCollapseEligibleRef = useRef(false); - const windowRefocusInFlightRef = useRef(false); - const composerScrollGestureRef = useRef(createComposerScrollGestureState()); const pendingThreadImportsRef = useRef>(new Map()); const unfocusedSplitPane = useSplitPaneFocus() === "unfocused"; const isRevertingCheckpointRef = useRef(isRevertingCheckpoint); @@ -1369,8 +1334,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) activeComposerMenuItemRef.current = activeComposerMenuItem; }); - const nonPersistedComposerImageIdSet = new Set(nonPersistedComposerImageIds); - const isComposerApprovalState = activePendingApproval !== null; // A send declines the approvals shown here to redirect the agent, so it steers the waiting // turn instead of queueing behind it. Approvals the typing guard holds stay unseen and pending. @@ -1422,7 +1385,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) hiddenBlockCount: restingControlsHiddenBlockCount, iconOnlyBlockCount: restingControlsIconOnlyBlockCount, controlsVisible: restingControlsVisible, - } = useRestingComposerControlsLayout(restingControlsHost ?? inlineRestingControlsHost); + } = useRestingComposerControlsLayout(inlineRestingControlsHost); const { attachControls: attachExpandedComposerControls, hiddenBlockCount: expandedControlsHiddenBlockCount, @@ -1711,43 +1674,24 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) setComposerCursor(cursor); resetComposerTrigger(trigger); setIsDragOverComposer(false); - setIsComposerScrollCollapsed(false); } // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- observes composer form resizes to switch the footer and primary actions between compact layouts useLayoutEffect(() => { const composerForm = composerFormRef.current; if (!composerForm) return; - const measureComposerFormWidth = () => composerForm.clientWidth; - const measureFooterCompactness = () => { - const composerFormWidth = measureComposerFormWidth(); - const footerCompact = shouldUseCompactComposerFooter(composerFormWidth, { - hasWideActions: composerFooterHasWideActions, - }); - const primaryActionsCompact = - footerCompact && - shouldUseCompactComposerPrimaryActions(composerFormWidth, { - hasWideActions: composerFooterHasWideActions, - }); - return { - primaryActionsCompact, - footerCompact, - }; + const measurePrimaryActionsCompact = () => { + const composerFormWidth = composerForm.clientWidth; + const options = { hasWideActions: composerFooterHasWideActions }; + return ( + shouldUseCompactComposerFooter(composerFormWidth, options) && + shouldUseCompactComposerPrimaryActions(composerFormWidth, options) + ); }; - const initialCompactness = measureFooterCompactness(); - setIsComposerPrimaryActionsCompact(initialCompactness.primaryActionsCompact); - setIsComposerFooterCompact(initialCompactness.footerCompact); + setIsComposerPrimaryActionsCompact(measurePrimaryActionsCompact()); return observeResize(composerForm, () => { - const nextCompactness = measureFooterCompactness(); - setIsComposerPrimaryActionsCompact((previous) => - previous === nextCompactness.primaryActionsCompact - ? previous - : nextCompactness.primaryActionsCompact, - ); - setIsComposerFooterCompact((previous) => - previous === nextCompactness.footerCompact ? previous : nextCompactness.footerCompact, - ); + setIsComposerPrimaryActionsCompact(measurePrimaryActionsCompact()); }); }, [composerFooterHasWideActions]); @@ -1818,16 +1762,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) syncComposerDraftPersistedAttachments, ]); - const expandComposerForEditorChange = () => { - suppressActiveComposerScrollGesture( - composerScrollGestureRef.current, - window.performance.now(), - COMPOSER_SCROLL_GESTURE_RESET_MS, - ); - setIsComposerScrollCollapsed(false); - }; - const expandComposerForEditorChangeStable = useStableCallback(expandComposerForEditorChange); - const retainedContextPayloads = useRetainedContextPayloads(composerDraftTargetKey); const convertTypedObjectLinkRef = useRef(null); @@ -1839,7 +1773,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) cursorAdjacentToMention: boolean, contextIds: string[], ) => { - expandComposerForEditorChange(); refreshWorkspaceSnapshotOnTyping(); if (activePendingProgress?.activeQuestion && pendingUserInputs.length > 0) { if (activePendingProgress.activeQuestion.allowCustomAnswer === false) return; @@ -2716,72 +2649,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) expandMobileComposer: expandMobileComposerStable, }); const hasBlockingComposerTopDrawer = pendingUserInputs.length > 0; - const hasImageAttachmentAttention = standaloneComposerImages.some((image) => { - const upload = uploadsByImageId[image.id]; - const failedInCurrentEnvironment = - supportsAttachmentUploads && - upload?.status === "failed" && - upload.environmentId === environmentId; - return nonPersistedComposerImageIdSet.has(image.id) && !failedInCurrentEnvironment; - }); - const composerHasExpandedChrome = - showComposerTopDrawer || - composerMenuOpen || - isStashMenuOpen || - isDragOverComposer || - isPreparingWorktree || - showProviderUnavailable || - projectSelectionRequired || - environmentUnavailable !== null || - composerSubmissionError !== null || - providerInputSubmissionError !== null || - hasImageAttachmentAttention || - preparingComposerImages.length > 0; - const isComposerResting = shouldUseRestingComposerLayout({ - isExistingThread: routeKind === "server" && activeThreadId !== null, - isMobileViewport, - isScrollCollapsed: isComposerScrollCollapsed, - hasExpandedChrome: composerHasExpandedChrome, - hasMultilinePrompt, - timelineOverflows, - }); - const expandedComposerImages = isComposerResting - ? standaloneComposerImages.filter((image) => pendingSnapShotIdSet.has(image.id)) - : standaloneComposerImages; - const composerControlsCollapsed = isComposerResting || isComposerCollapsedMobile; - const showInlineRestingControls = composerControlsCollapsed && restingControlsHost === null; - const composerControlsVisibleInStrip = - composerControlsCollapsed && restingControlsHost !== null && restingControlsVisible; - const composerControlsHidden = composerControlsCollapsed && !restingControlsVisible; + const composerControlsCollapsed = isComposerCollapsedMobile; + // The collapsed mobile web composer keeps a second controls row inside the card. + const showInlineRestingControls = isComposerCollapsedMobile; + const composerControlsHidden = showInlineRestingControls && !restingControlsVisible; if (composerControlsHidden && isComposerModelPickerOpen) { setIsComposerModelPickerOpen(false); } - // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- reports whether resting controls show in the strip to ChatView before paint - useLayoutEffect(() => { - onRestingControlsVisibilityChange(composerControlsVisibleInStrip); - }, [composerControlsVisibleInStrip, onRestingControlsVisibilityChange]); - // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- reports the resting state to ChatView's ref before paint - useLayoutEffect(() => { - onRestingChange(isComposerResting); - }, [isComposerResting, onRestingChange]); - const [restingActionsElement, setRestingActionsElement] = useState(null); - const [restingActionsWidth, setRestingActionsWidth] = useState(null); - // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- observes resizes of the resting actions DOM node to measure its width - useLayoutEffect(() => { - if (!isComposerResting || !restingActionsElement) return; - const measure = () => setRestingActionsWidth(restingActionsElement.offsetWidth); - measure(); - return observeResize(restingActionsElement, measure); - }, [isComposerResting, restingActionsElement]); - const showMoreRestingImages = () => { - if (isComposerCollapsedMobile) { - expandMobileComposer(); - return; - } - setIsComposerScrollCollapsed(false); - setIsComposerFocused(true); - scheduleComposerFocus(); - }; const collapsedComposerImagePreviews = standaloneComposerImages.length > 0 ? ( ) : null; const { active: panelAnimationsActive, durationMs: panelAnimationDurationMs } = usePanelAnimationSettings(); const composerMainSurfaceRef = useComposerRestingTransition( composerControlsCollapsed, - isComposerResting, restingComposerControlsRef, onComposerOverlayHeightChange, panelAnimationsActive, panelAnimationDurationMs, ); - const canTrackComposerScrollGesture = - routeKind === "server" && activeThreadId !== null && !isMobileViewport; - const canScrollCollapseComposer = - canTrackComposerScrollGesture && - settings.composerCollapseOnScroll && - !hasMultilinePrompt && - !composerHasExpandedChrome; - // Clear rather than mask: once eligibility drops, the composer stays expanded until the next - // scroll gesture even if it becomes eligible again. - if (!canScrollCollapseComposer && isComposerScrollCollapsed) { - setIsComposerScrollCollapsed(false); - } - const composerScrollCollapseEligible = canScrollCollapseComposer && !isComposerResting; - // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- publishes the committed scroll-collapse eligibility to a ref read by the wheel listener - useLayoutEffect(() => { - composerScrollCollapseEligibleRef.current = composerScrollCollapseEligible; - }); - - // Window activation re-fires focus; ignore that frame to keep the composer collapsed. - // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- window focus listener and animation frame that ignore activation focus while the composer is collapsed - useEffect(() => { - if (!isComposerScrollCollapsed) return; - let frame: number | null = null; - const onWindowFocus = () => { - windowRefocusInFlightRef.current = true; - if (frame !== null) window.cancelAnimationFrame(frame); - frame = window.requestAnimationFrame(() => { - frame = null; - windowRefocusInFlightRef.current = false; - }); - }; - window.addEventListener("focus", onWindowFocus); - return () => { - window.removeEventListener("focus", onWindowFocus); - if (frame !== null) window.cancelAnimationFrame(frame); - windowRefocusInFlightRef.current = false; - }; - }, [isComposerScrollCollapsed]); - - // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- subscribes a capture-phase document wheel listener that collapses the composer on timeline scroll - useEffect(() => { - if (!canTrackComposerScrollGesture) return; - - const finishScrollGesture = () => { - if (composerScrollCollapseTimeoutRef.current !== null) { - window.clearTimeout(composerScrollCollapseTimeoutRef.current); - } - composerScrollCollapseTimeoutRef.current = null; - resetComposerScrollGesture(composerScrollGestureRef.current); - }; - const handleTimelineWheel = (event: WheelEvent) => { - if (event.ctrlKey || !(event.target instanceof Element)) { - return; - } - - const scrollNode = getTimelineScrollableNode(); - if (!scrollNode) return; - const targetsTimeline = isTimelineScrollTarget(event.target, scrollNode, event.deltaY); - if ( - !scrollNode.contains(event.target) && - !composerScrollGestureRef.current.collapseSuppressed - ) - return; - - if (composerScrollCollapseTimeoutRef.current !== null) { - window.clearTimeout(composerScrollCollapseTimeoutRef.current); - } - composerScrollCollapseTimeoutRef.current = window.setTimeout( - finishScrollGesture, - COMPOSER_SCROLL_GESTURE_RESET_MS, - ); - - const canScrollInGestureDirection = - targetsTimeline && - (event.deltaY < 0 - ? scrollNode.scrollTop > 0 - : scrollNode.scrollTop < scrollNode.scrollHeight - scrollNode.clientHeight); - const deltaPx = - Math.abs(event.deltaY) * - (event.deltaMode === WheelEvent.DOM_DELTA_LINE - ? 16 - : event.deltaMode === WheelEvent.DOM_DELTA_PAGE - ? scrollNode.clientHeight - : 1); - const shouldCollapse = recordComposerScrollGestureEvent(composerScrollGestureRef.current, { - now: window.performance.now(), - deltaPx, - collapseThresholdPx: COMPOSER_SCROLL_COLLAPSE_THRESHOLD_PX, - collapseEligible: targetsTimeline && composerScrollCollapseEligibleRef.current, - canScrollInGestureDirection, - scrollsTowardLogicalEnd: event.deltaY > 0 && isTimelineAtLogicalEnd(), - }); - if (!shouldCollapse) { - return; - } - - setIsComposerScrollCollapsed(true); - }; - - document.addEventListener("wheel", handleTimelineWheel, { capture: true, passive: true }); - return () => { - document.removeEventListener("wheel", handleTimelineWheel, true); - finishScrollGesture(); - }; - }, [ - canTrackComposerScrollGesture, - getTimelineScrollableNode, - isTimelineAtLogicalEnd, - setIsComposerScrollCollapsed, - ]); - - const restingHiddenBlockCount = composerControlsCollapsed + const restingHiddenBlockCount = showInlineRestingControls ? restingControlsHiddenBlockCount - : expandedControlsHiddenBlockCount; - const iconOnlyBlockCount = composerControlsCollapsed + : composerControlsCollapsed + ? 0 + : expandedControlsHiddenBlockCount; + const iconOnlyBlockCount = showInlineRestingControls ? restingControlsIconOnlyBlockCount - : expandedControlsIconOnlyBlockCount; + : composerControlsCollapsed + ? 0 + : expandedControlsIconOnlyBlockCount; const composerControls = ( { composerEditorRef.current?.focusAt(cursor); }, - restoreAfterTimelineReachedEnd, - collapseForTimelineScrollKey: (key) => { - const scrollNode = getTimelineScrollableNode(); - if ( - composerScrollCollapseEligibleRef.current && - scrollNode && - shouldCollapseComposerForScrollKey({ - key, - scrollTop: scrollNode.scrollTop, - scrollHeight: scrollNode.scrollHeight, - clientHeight: scrollNode.clientHeight, - isAtLogicalEnd: isTimelineAtLogicalEnd(), - }) - ) { - setIsComposerScrollCollapsed(true); - } - }, addDroppedFiles: (files: File[]) => addComposerAttachments(files).then((inserted) => { if (!inserted) focusComposer(); @@ -3532,10 +3277,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) window.cancelAnimationFrame(composerBlurFrameRef.current); composerBlurFrameRef.current = null; } - flushSync(() => { - setIsComposerScrollCollapsed(false); - setIsComposerFocused(true); - }); + flushSync(() => setIsComposerFocused(true)); const shell = composerFormRef.current?.closest('[data-slot="composer-shell"]'); const trigger = Array.from( shell?.querySelectorAll( @@ -3547,6 +3289,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ); if (!trigger) return; trigger.focus({ preventScroll: true }); + if (command === "composer.mode") markOpenToPermissions(trigger); trigger.click(); }, compactContext: compactThreadContextStable, @@ -3735,10 +3478,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) compactThreadContextStable, saveMessageDraftStable, openMessageDraftStable, - restoreAfterTimelineReachedEnd, - getTimelineScrollableNode, - isTimelineAtLogicalEnd, - setIsComposerScrollCollapsed, ], ); @@ -3747,11 +3486,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) !isComposerCollapsedMobile && (uncommittedSnapShotIds.length > 0 || composerVideos.length > 0 || - expandedComposerImages.length > 0 || + standaloneComposerImages.length > 0 || preparingComposerImages.length > 0) ? ( { - const target = event.target; - if (isInsideRestingComposerControlScope(target)) return; - if (isInsideCollapsedComposerControls(target)) return; - if (!(target instanceof Element)) return; - const isInteractive = Boolean( - target.closest('button, a, input, select, [role="button"], [role="menuitem"]'), - ); - if (isInteractive) return; - - setIsComposerScrollCollapsed(false); - if (isComposerResting && !target.closest('[data-testid="composer-editor"]')) { - event.preventDefault(); - setIsComposerFocused(true); - scheduleComposerFocus(); - } - }} onFocusCapture={(event) => { const activeElement = event.target; if ( @@ -3836,9 +3558,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) if (isInsideCollapsedComposerControls(activeElement)) { return; } - if (!windowRefocusInFlightRef.current) { - setIsComposerScrollCollapsed(false); - } if (composerBlurFrameRef.current !== null) { window.cancelAnimationFrame(composerBlurFrameRef.current); composerBlurFrameRef.current = null; @@ -3874,23 +3593,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) data-chat-composer-form="true" {...threadContextDropTargetProps()} > - {composerControlsCollapsed && restingControlsHost - ? createPortal( -
- {composerControls} -
, - restingControlsHost, - ) - : null} @@ -3934,10 +3636,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
- {onStartFrom && !isComposerResting ? ( + {onStartFrom ? ( +
{previewFile ? ( - {isComposerResting ? collapsedComposerImagePreviews : null} {showMobilePendingAnswerActions ? (
0 && "pt-2", - isComposerFooterCompact ? "gap-1.5" : "gap-2 sm:gap-0", showMobilePendingAnswerActions && "hidden sm:flex", - isComposerResting && - "absolute right-px z-10 h-12 w-auto gap-0 py-0 sm:gap-0 sm:py-0", - isComposerResting && - (showInlineRestingControls ? "bottom-[calc(2rem+1px)]" : "bottom-px"), )} > + {composerAttachButton ?
{composerAttachButton}
: null}
- {composerControlsCollapsed ? null : composerControls} -
- -
- {composerAttachButton} +
+ {composerControls} +
; + runtimeModeOptions: ReadonlyArray; showInteractionModeToggle: boolean; traitsMenuContent?: ReactNode; + showPermissions?: boolean; accountMenu?: { activeInstanceId: ProviderInstanceId; accounts: ReadonlyArray; @@ -39,6 +38,7 @@ export const CompactComposerControlsMenu = memo(function CompactComposerControls const composerFloatingLayerProps = useComposerMenuProps(); const size = props.size ?? "sm"; const [open, setOpen] = useComposerMenuState(props.hidden); + const triggerRef = useRef(null); const accountMenu = props.accountMenu; const lockedAccount = accountMenu !== undefined && accountMenu.accounts.length < 2 @@ -46,17 +46,30 @@ export const CompactComposerControlsMenu = memo(function CompactComposerControls (account) => account.instanceId === accountMenu.activeInstanceId, ) ?? accountMenu.accounts[0]) : undefined; + const showAccountSection = + accountMenu !== undefined && (accountMenu.accounts.length > 1 || lockedAccount !== undefined); + const showPermissions = props.showPermissions !== false; return ( - + focusRequestedPermission(isOpen, triggerRef.current)} + > } @@ -67,7 +80,7 @@ export const CompactComposerControlsMenu = memo(function CompactComposerControls {props.traitsMenuContent ? ( <> {props.traitsMenuContent} - + {props.showInteractionModeToggle || showPermissions ? : null} ) : null} {props.showInteractionModeToggle ? ( @@ -83,26 +96,22 @@ export const CompactComposerControlsMenu = memo(function CompactComposerControls Chat Plan - + {showPermissions ? : null} ) : null} -
Access
- { - if (!value || value === props.runtimeMode) return; - props.onRuntimeModeChange(value as RuntimeMode); - }} - > - {props.runtimeModeOptions.map((option) => ( - - {option.label} - - ))} - + {showPermissions ? ( + + ) : null} + {showAccountSection && + (props.traitsMenuContent || props.showInteractionModeToggle || showPermissions) ? ( + + ) : null} {accountMenu && accountMenu.accounts.length > 1 ? ( <> -
Account
) : lockedAccount ? ( <> -
Account
{lockedAccount.displayName}

diff --git a/apps/web/src/components/chat/ComposerAttachMenu.tsx b/apps/web/src/components/chat/ComposerAttachMenu.tsx index 138789e436f2b..fdf3f094a113f 100644 --- a/apps/web/src/components/chat/ComposerAttachMenu.tsx +++ b/apps/web/src/components/chat/ComposerAttachMenu.tsx @@ -71,7 +71,8 @@ export const ComposerAttachMenu = memo(function ComposerAttachMenu(props: { } > - + {/* The clip's weight sits in its upper loop; 1px down reads as centered on the row. */} + diff --git a/apps/web/src/components/chat/ComposerControl.tsx b/apps/web/src/components/chat/ComposerControl.tsx index 6f1824fd72642..9ec5d4e1144d4 100644 --- a/apps/web/src/components/chat/ComposerControl.tsx +++ b/apps/web/src/components/chat/ComposerControl.tsx @@ -1,11 +1,9 @@ import { mergeProps } from "@base-ui/react/merge-props"; import { Select as SelectPrimitive } from "@base-ui/react/select"; import { useRender } from "@base-ui/react/use-render"; -import type { ComponentProps, ComponentType, SVGProps } from "react"; -import { ChevronDownIcon } from "lucide-react"; +import type { ComponentType, SVGProps } from "react"; import { cn } from "~/lib/utils"; -import { Separator } from "../ui/separator"; export type ComposerControlSize = "sm" | "xs"; @@ -66,43 +64,6 @@ export function ComposerControlIcon({ ); } -export function ComposerControlChevron({ - className, - size = "sm", -}: { - className?: string; - size?: ComposerControlSize; -} = {}) { - return ( -

+ focusRequestedPermission(isOpen, permissionsChipRef.current) + } + > - - {runtimeModeOption.description} + {runtimeModeOption.label} + + + {permissionsTooltip} + + + + + ); + return ( + <> + {accessDisplay === "hidden" ? null : permissionsChip} {interactionModeToggle} ); }); +/** + * Permission modes as one horizontal toggle, with the shown mode named below. + * Each segment is a real menu radio item, so arrow keys reach it from the rest of the menu + * and a pick closes the menu. Lives inside a Menu: the permissions chip, the effort menu, + * or the overflow menu. + */ +export function ComposerPermissionsSection(props: { + runtimeMode: RuntimeMode; + runtimeModeOptions: ReadonlyArray< + { mode: RuntimeMode } & (typeof runtimeModeConfig)[RuntimeMode] + >; + onRuntimeModeChange: (mode: RuntimeMode) => void; +}) { + // Hover or keyboard focus previews a mode's name and description before a pick. + const [previewMode, setPreviewMode] = useState(null); + const shownMode = previewMode ?? props.runtimeMode; + const shown = + props.runtimeModeOptions.find((option) => option.mode === shownMode) ?? + supervisedRuntimeModeOption; + const descriptionId = useId(); + return ( +
+
Permissions
+ { + if (value !== props.runtimeMode) props.onRuntimeModeChange(value); + }} + className="flex gap-0.5 rounded-lg bg-input/40 p-0.5" + // Menus navigate with Up/Down only; the row reads horizontally, so Left/Right move too. + onKeyDown={(event) => { + if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return; + const items = Array.from( + event.currentTarget.querySelectorAll('[role="menuitemradio"]'), + ); + const index = items.findIndex((item) => item === document.activeElement); + const next = items[index + (event.key === "ArrowRight" ? 1 : -1)]; + if (index < 0 || !next) return; + event.preventDefault(); + event.stopPropagation(); + next.focus(); + }} + > + {props.runtimeModeOptions.map((option) => { + const OptionIcon = option.icon; + return ( + setPreviewMode(option.mode)} + onPointerLeave={() => setPreviewMode(null)} + onFocus={() => setPreviewMode(option.mode)} + onBlur={() => setPreviewMode(null)} + className="flex h-6 flex-1 cursor-pointer items-center justify-center rounded-md text-muted-foreground outline-none data-checked:bg-input/72 data-checked:text-foreground data-highlighted:text-foreground not-data-checked:data-highlighted:bg-accent focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset" + > + + ); + })} + + {/* Static per-mode descriptions for screen readers; the preview below is visual only. */} + {props.runtimeModeOptions.map((option) => ( + + {option.description} + + ))} + +
+ ); +} + export const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(props: { compact: boolean; canOperateThread: boolean; diff --git a/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx b/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx index 958262bdcace6..e6f21b6e0b8a2 100644 --- a/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx +++ b/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx @@ -3,10 +3,8 @@ import { memo, use, useEffect, useEffectEvent, useRef, useState } from "react"; import { BackgroundThreadContext } from "./backgroundThread"; import { useStableCallback } from "~/hooks/useStableCallback"; import { type PendingUserInput } from "../../session-logic"; -import { - derivePendingUserInputProgress, - type PendingUserInputDraftAnswer, -} from "../../pendingUserInput"; +import type { PendingUserInputDraftAnswer } from "@t3tools/client-runtime/state/pending-user-input"; +import { derivePendingUserInputProgress } from "../../pendingUserInput"; import { CheckboxMark } from "../ui/checkbox"; import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible"; import { Kbd } from "../ui/kbd"; diff --git a/apps/web/src/components/chat/ComposerPermissionsSection.browser.test.tsx b/apps/web/src/components/chat/ComposerPermissionsSection.browser.test.tsx new file mode 100644 index 0000000000000..74ab036f411e1 --- /dev/null +++ b/apps/web/src/components/chat/ComposerPermissionsSection.browser.test.tsx @@ -0,0 +1,151 @@ +import type { RuntimeMode } from "@t3tools/contracts"; +import { type ReactNode, useRef, useState } from "react"; +import { expect, it, vi } from "vite-plus/test"; +import { page, userEvent } from "vite-plus/test/browser"; + +import { render } from "../../../test/render"; +import { ComposerHandleContext, type ComposerHandleRef } from "../../composerHandleContext"; +import { Menu, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "../ui/menu"; +import { + focusRequestedPermission, + markOpenToPermissions, + useComposerMenuProps, +} from "./composerEventScope"; +import { ComposerFooterModeControls, ComposerPermissionsSection } from "./ComposerFooterControls"; +import { runtimeModeConfig, runtimeModeOptions } from "./runtimeModeConfig"; + +const options = runtimeModeOptions.map((mode) => ({ mode, ...runtimeModeConfig[mode] })); + +// Mirrors the effort menu: effort radio items first, then the Permissions section. +function EffortMenu() { + const [effort, setEffort] = useState("high"); + const [mode, setMode] = useState("full-access"); + const composerMenuProps = useComposerMenuProps(); + const triggerRef = useRef(null); + return ( + <> + focusRequestedPermission(open, triggerRef.current)}> + Effort + + + Low + High + + + + +

{mode}

+ + ); +} + +it("reaches Permissions from the effort items with arrow keys, previews, and picks with Enter", async () => { + await using screen = await render(, { router: false }); // no routes involved + const trigger = screen.getByRole("button", { name: "Effort" }); + await userEvent.tab(); + await expect.element(trigger).toHaveFocus(); + await userEvent.keyboard("{ArrowDown}"); + await expect.element(page.getByRole("menuitemradio", { name: "Low" })).toHaveFocus(); + + await userEvent.keyboard("{ArrowDown}{ArrowDown}"); + const supervised = page.getByRole("menuitemradio", { name: "Supervised" }); + await expect.element(supervised).toHaveFocus(); + await expect + .element(supervised) + .toHaveAccessibleDescription("Ask before commands and file changes."); + + await userEvent.keyboard("{ArrowRight}"); + await expect + .element(page.getByRole("menuitemradio", { name: "Auto-accept edits" })) + .toHaveFocus(); + await userEvent.keyboard("{ArrowLeft}{ArrowLeft}"); + await expect.element(page.getByRole("menuitemradio", { name: "Supervised" })).toHaveFocus(); + + await userEvent.keyboard("{ArrowUp}"); + await expect.element(page.getByRole("menuitemradio", { name: "High" })).toHaveFocus(); + + await userEvent.keyboard("{ArrowDown}{ArrowDown}{Enter}"); + await expect.element(screen.getByRole("status")).toHaveTextContent("auto-accept-edits"); + await expect + .element(page.getByRole("menuitemradio", { name: "Supervised" })) + .not.toBeInTheDocument(); + await expect.element(trigger).toHaveFocus(); +}); + +it("marks the current mode as checked and closes on Escape", async () => { + await using screen = await render(, { router: false }); // no routes involved + await userEvent.tab(); + await userEvent.keyboard("{ArrowDown}"); + const fullAccess = page.getByRole("menuitemradio", { name: "Full access" }); + await expect.element(fullAccess).toHaveAttribute("aria-checked", "true"); + await userEvent.keyboard("{Escape}"); + await expect.element(fullAccess).not.toBeInTheDocument(); + await expect.element(screen.getByRole("button", { name: "Effort" })).toHaveFocus(); +}); + +// The real composer menu focus rule (useComposerMenuProps), with a stub composer handle. +function withComposer(children: ReactNode) { + const handle = { focusAtEnd: vi.fn() }; + const ref = { current: handle } as unknown as ComposerHandleRef; + return { handle, ui: {children} }; +} + +it("returns focus to the editor after a pick", async () => { + const { handle, ui } = withComposer(); + await using screen = await render(ui, { router: false }); // no routes involved + await userEvent.tab(); + await userEvent.keyboard("{ArrowDown}"); + await expect.element(page.getByRole("menuitemradio", { name: "Low" })).toHaveFocus(); + await userEvent.keyboard("{ArrowDown}{ArrowDown}{Enter}"); + await expect.element(screen.getByRole("status")).toHaveTextContent("approval-required"); + await vi.waitFor(() => expect(handle.focusAtEnd).toHaveBeenCalled()); +}); + +it("opens on the checked permission when composer.mode marked the trigger", async () => { + await using screen = await render(, { router: false }); // no routes involved + const trigger = screen.getByRole("button", { name: "Effort" }); + markOpenToPermissions(trigger.element() as HTMLElement); + await userEvent.click(trigger); + await expect.element(page.getByRole("menuitemradio", { name: "Full access" })).toHaveFocus(); + + // The mark is used once: a plain reopen lands on the menu's usual first focus. + await userEvent.keyboard("{Escape}"); + await userEvent.click(trigger); + await expect.element(page.getByRole("menuitemradio", { name: "Full access" })).not.toHaveFocus(); +}); + +// Mirrors ComposerControlsBar: the chip shows only while the mode differs from the default. +function PermissionsChip() { + const [mode, setMode] = useState("approval-required"); + return ( +
+ + {mode === "full-access" ? null : ( + {}} + onRuntimeModeChange={setMode} + /> + )} +
+ ); +} + +it("returns focus to the editor when picking the default hides the chip", async () => { + const { handle, ui } = withComposer(); + await using screen = await render(ui, { router: false }); // no routes involved + await userEvent.click(screen.getByRole("button", { name: "Permissions: Supervised" })); + await userEvent.click(page.getByRole("menuitemradio", { name: "Full access" })); + await vi.waitFor(() => expect(handle.focusAtEnd).toHaveBeenCalled()); + await expect.element(screen.getByRole("button", { name: "Effort" })).not.toHaveFocus(); +}); diff --git a/apps/web/src/components/chat/ComposerSurface.tsx b/apps/web/src/components/chat/ComposerSurface.tsx index 5a9f398e3c6e4..13614b8c9bd4c 100644 --- a/apps/web/src/components/chat/ComposerSurface.tsx +++ b/apps/web/src/components/chat/ComposerSurface.tsx @@ -3,14 +3,14 @@ import type { ComponentProps } from "react"; import { cn } from "~/lib/utils"; function Shell({ - contextStrip = false, + contextRow = false, className, ...props -}: ComponentProps<"div"> & { contextStrip?: boolean }) { +}: ComponentProps<"div"> & { contextRow?: boolean }) { return (
) { return (
) { data-chat-composer-main-surface="true" className={cn( "group relative z-10 rounded-2xl p-px", - "group-data-model-strip-transition/composer-surface:bg-(--chat-composer-glass-surface)/(--glass-opacity) group-data-model-strip-transition/composer-surface:backdrop-blur-(--glass-blur) group-data-model-strip-transition/composer-surface:backdrop-saturate-(--glass-saturation)", outlineClasses, - contextSeamClasses, - "after:z-20 after:hidden group-has-data-[composer-banner-surface=attached]/composer-surface:after:block group-data-model-strip-transition/composer-surface:after:block", + "after:z-20 after:hidden group-has-data-[composer-banner-surface=attached]/composer-surface:after:block", "group-has-data-[composer-banner-surface=attached]/composer-surface:bg-(--chat-composer-glass-surface)/(--glass-opacity) group-has-data-[composer-banner-surface=attached]/composer-surface:backdrop-blur-(--glass-blur) group-has-data-[composer-banner-surface=attached]/composer-surface:backdrop-saturate-(--glass-saturation)", "group-has-data-[composer-banner-surface=attached]/composer-surface:shadow-composer dark:group-has-data-[composer-banner-surface=attached]/composer-surface:shadow-none", "not-supports-[((backdrop-filter:blur(1px))_or_(-webkit-backdrop-filter:blur(1px)))]:group-has-data-[composer-banner-surface=attached]/composer-surface:bg-(--chat-composer-glass-surface)", @@ -77,19 +68,13 @@ function Main({ className, ...props }: ComponentProps<"div">) { ); } -// Position hints keep class merging from dropping mask color stops. -// Dark goes transparent only where the Shell backdrop paints the strip; scoping it keeps dark: from beating the fills below. +/** A plain row under the card (environment, worktree, branch) with no surface of its own. */ function ContextStrip({ className, ...props }: ComponentProps<"div">) { return (
void; + readonly onDismiss: () => void; +}): ComposerBannerStackItem { + return { + id: "fallback-model", + variant: "info", + icon: , + title: `Using ${fallbackModelName}`, + description: `${defaultProviderName} is out of usage${resetsAt ? ` · resets ${formatUpcomingTimestamp(resetsAt, timestampFormat)}` : ""}`, + actions: ( + + ), + 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/EffortGlyph.test.ts b/apps/web/src/components/chat/EffortGlyph.test.ts new file mode 100644 index 0000000000000..712e4b796ba61 --- /dev/null +++ b/apps/web/src/components/chat/EffortGlyph.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { effortBarHeights, resolveEffortGlyph } from "./EffortGlyph"; + +const descriptor = (ids: ReadonlyArray, promptInjectedValues?: ReadonlyArray) => ({ + id: "effort", + label: "Reasoning", + type: "select" as const, + options: ids.map((id) => ({ id, label: id[0]!.toUpperCase() + id.slice(1) })), + ...(promptInjectedValues ? { promptInjectedValues } : {}), +}); + +describe("resolveEffortGlyph", () => { + const claude = descriptor( + ["low", "medium", "high", "xhigh", "max", "ultracode", "ultrathink"], + ["ultrathink"], + ); + + it("fills bars up to the current level and leaves max and workflow levels off the scale", () => { + expect(resolveEffortGlyph(claude, "xhigh")).toEqual({ + count: 4, + filled: 4, + special: null, + label: "Xhigh", + }); + }); + + it("names max instead of drawing it", () => { + expect(resolveEffortGlyph(claude, "max")?.special).toBe("Max"); + }); + + it("names Ultracode and Ultrathink instead of drawing a level", () => { + expect(resolveEffortGlyph(claude, "ultracode")?.special).toBe("Ultracode"); + expect(resolveEffortGlyph(claude, "ultrathink")?.special).toBe("Ultrathink"); + }); + + it("draws the lowest level as one bar, and an off level as none", () => { + expect(resolveEffortGlyph(claude, "low")).toMatchObject({ count: 4, filled: 1 }); + expect(resolveEffortGlyph(claude, "medium")).toMatchObject({ count: 4, filled: 2 }); + expect(resolveEffortGlyph(descriptor(["off", "low", "high"]), "off")).toEqual({ + count: 2, + filled: 0, + special: null, + label: "Off", + }); + }); + + it("falls back to the word when the value is unknown or the scale has one level", () => { + expect(resolveEffortGlyph(claude, "unknown")).toBeNull(); + expect(resolveEffortGlyph(descriptor(["high"]), "high")).toBeNull(); + }); +}); + +describe("effortBarHeights", () => { + it.each([3, 4, 5, 6, 7])( + "rises by one whole-pixel step and ends at full height: %i bars", + (count) => { + const heights = effortBarHeights(count, 12); + expect(heights).toHaveLength(count); + expect(heights.at(-1)).toBe(12); + const steps = new Set(heights.slice(1).map((height, index) => height - heights[index]!)); + expect(steps.size).toBe(1); + const [step] = steps; + expect(Number.isInteger(step) && step! > 0).toBe(true); + }, + ); +}); diff --git a/apps/web/src/components/chat/EffortGlyph.tsx b/apps/web/src/components/chat/EffortGlyph.tsx new file mode 100644 index 0000000000000..2e033c6caa8da --- /dev/null +++ b/apps/web/src/components/chat/EffortGlyph.tsx @@ -0,0 +1,86 @@ +import type { ProviderOptionDescriptor } from "@t3tools/contracts"; + +import type { ComposerControlSize } from "./ComposerControl"; + +const GLYPH_HEIGHT_SM = 14; +const GLYPH_HEIGHT_XS = 12; +// Bars stop this far above the box's bottom, which puts their flat base on the text baseline. +const BASELINE_INSET = 2; + +type SelectDescriptor = Extract; + +// Ultracode changes the workflow, not only intensity, so it keeps its name like Ultrathink. +// Max is named too, so the full scale stops short of it. +const NAMED_LEVELS = new Set(["ultracode", "max"]); +const OFF_LEVELS = new Set(["off", "none"]); + +/** + * Maps a reasoning select to bars: one per ordinary level, filled up to the current one, with an + * off level as none filled. Max, prompt-injected and workflow levels are named instead of drawn. + */ +export function resolveEffortGlyph(descriptor: SelectDescriptor, current: string | null) { + const special = new Set([...(descriptor.promptInjectedValues ?? []), ...NAMED_LEVELS]); + const levels = descriptor.options.filter((option) => !special.has(option.id)); + if (levels.length < 2 || current === null) return null; + const startsAtOff = OFF_LEVELS.has(levels[0]!.id); + const count = startsAtOff ? levels.length - 1 : levels.length; + if (special.has(current)) { + const label = descriptor.options.find((option) => option.id === current)?.label ?? current; + return { count, filled: count, special: label, label }; + } + const index = levels.findIndex((option) => option.id === current); + if (index < 0) return null; + return { + count, + filled: startsAtOff ? index : index + 1, + special: null, + label: levels[index]!.label, + }; +} + +/** Bar heights that rise by one whole-pixel step, from about half height up to `maxBar`. */ +export function effortBarHeights(count: number, maxBar: number): number[] { + // Rounding each bar separately mixes 1px and 2px steps, so round the step once. + const step = Math.max(1, Math.round(maxBar / 2 / Math.max(count - 1, 1))); + return Array.from({ length: count }, (_, index) => maxBar - (count - 1 - index) * step); +} + +/** + * Rising bars on whole pixels so they stay sharp on 1x displays. Color follows the text. + * The box is centered like the icons beside it; the bars sit on the text baseline inside it. + */ +export function EffortGlyph(props: { count: number; filled: number; size?: ComposerControlSize }) { + const height = props.size === "xs" ? GLYPH_HEIGHT_XS : GLYPH_HEIGHT_SM; + const step = 4; + const width = step * props.count - 2; + const maxBar = height - BASELINE_INSET; + return ( + + ); +} diff --git a/apps/web/src/components/chat/LinkRequestCard.tsx b/apps/web/src/components/chat/LinkRequestCard.tsx index 728c8fe61efa2..a9bdd0fb1114a 100644 --- a/apps/web/src/components/chat/LinkRequestCard.tsx +++ b/apps/web/src/components/chat/LinkRequestCard.tsx @@ -28,8 +28,7 @@ import { useId, useRef, useState } from "react"; import { useEnvironmentScope, usePreparedConnection } from "../../state/session"; import { mintPairingCodeOn } from "../../state/peerPairingCode"; -import { useLinkableMachines } from "../../state/peerLinks"; -import { serverEnvironment } from "../../state/server"; +import { peerLinkEnvironment, useLinkableMachines } from "../../state/peerLinks"; import { useAtomCommand } from "../../state/use-atom-command"; import { accessConfig } from "../auth/ConnectAgentSurface"; import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon"; @@ -88,13 +87,13 @@ function PendingLinkRequest(props: { const [open, setOpen] = useState(false); const [declining, setDeclining] = useState(false); const [error, setError] = useState(null); - const answer = useAtomCommand(serverEnvironment.answerLinkRequest, { + const answer = useAtomCommand(peerLinkEnvironment.answerRequest, { label: "decline link request", reportFailure: false, reportDefect: false, }); const canAnswer = useAtomValue( - serverEnvironment.answerLinkRequest.permissionAtom(props.environmentId), + peerLinkEnvironment.answerRequest.permissionAtom(props.environmentId), ); const target = linkRequestTargetName(item); @@ -223,7 +222,7 @@ function LinkRequestDialog(props: { const [mintFailed, setMintFailed] = useState>(new Set()); // Enter then a click can both run before a re-render; this guard is synchronous. const inFlight = useRef(false); - const answer = useAtomCommand(serverEnvironment.answerLinkRequest, { + const answer = useAtomCommand(peerLinkEnvironment.answerRequest, { label: "answer link request", // The failure cause holds the request, and with it the code; keep it out of the console. reportFailure: false, diff --git a/apps/web/src/components/chat/MarkdownExternalLink.tsx b/apps/web/src/components/chat/MarkdownExternalLink.tsx index d769473999b7a..de64a0c51d1ba 100644 --- a/apps/web/src/components/chat/MarkdownExternalLink.tsx +++ b/apps/web/src/components/chat/MarkdownExternalLink.tsx @@ -58,13 +58,19 @@ function leadingExternalLinkTextLength(text: string): number { return Math.min(text.length, 1); } +// Long words such as URLs can break after any character so they fit narrow +// table columns. Short ones, like a PR number, stay whole. function breakableExternalLinkText(text: string): ReactNode[] { - return Array.from(text, (character, index) => ( - - {character} - - - )); + return Array.from(text.matchAll(/\s+|\S+/g), ({ 0: word, index: start }) => + word.length <= 16 + ? word + : Array.from(word, (character, offset) => ( + + {character} + + + )), + ); } export function MarkdownExternalLinkContent({ diff --git a/apps/web/src/components/chat/MarkdownTable.tsx b/apps/web/src/components/chat/MarkdownTable.tsx index 9859ff97ad5eb..ee1e1825adcd2 100644 --- a/apps/web/src/components/chat/MarkdownTable.tsx +++ b/apps/web/src/components/chat/MarkdownTable.tsx @@ -1,8 +1,7 @@ -import { Check, Copy, Maximize2, Minimize2 } from "lucide"; -import { useRef, useState, type ComponentProps } from "react"; +import { Check, Copy } from "lucide"; +import { useRef, type ComponentProps } from "react"; import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; -import { getClientSettings } from "../../hooks/useSettings"; import { serializeTableElementToCsv, serializeTableElementToMarkdown, @@ -16,13 +15,11 @@ import { reportMarkdownActionFailure } from "./markdownActionFailure"; export function MarkdownTable({ children, ...props }: ComponentProps<"table">) { const containerRef = useRef(null); - const [expanded, setExpanded] = useState(() => getClientSettings().wordWrap); const { copyToClipboard, isCopied: copied } = useCopyToClipboard<"markdown" | "csv">({ timeout: 1200, onError: (cause, format) => reportMarkdownActionFailure({ operation: "copy-table", format }, cause), }); - const expandLabel = expanded ? "Collapse table cells" : "Expand table cells"; const copyLabel = copied ? "Copied" : "Copy table"; const handleCopy = (format: "markdown" | "csv") => { @@ -37,33 +34,12 @@ export function MarkdownTable({ children, ...props }: ComponentProps<"table">) { }; return ( -
+
{children}
{/* Stays within the row's px-1: the chat lane clips sideways overflow. */} -
- - setExpanded((value) => !value)} - aria-label={expandLabel} - /> - } - > - - - {expandLabel} - +
{ expect(maxNestedCommits).toBeLessThan(20); }); - it("keeps reporting overflow while the composer inset changes", async () => { - setup(); - const props = baseProps(); - const overflowReports: boolean[] = []; - const replies = Array.from({ length: 40 }, (_, index) => - message(`Reply ${index}\n\n${"line\n\n".repeat(8)}`, { role: "assistant" }, `r-${index}`), - ); - const timeline = (inset: number) => ( - overflowReports.push(overflows)} - /> - ); - await using screen = await renderElement(timeline(110)); - await vi.waitFor(() => expect(overflowReports.at(-1)).toBe(true)); - overflowReports.length = 0; - // The resting composer resizes with overflow; a false report here flips it back. - await screen.rerender(timeline(148)); - expect(overflowReports).not.toContain(false); - }); - it("keeps the first and last message headings reachable from either end", async () => { setup(); const props = baseProps(); diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index b2b6ff95b2f63..bf00230d2708b 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -2832,6 +2832,13 @@ describe("v2 run and attempt history", () => { runId, label: "Old command", tone: "tool" as const, + projectedItem: { + sourceItemId: "old-work-anchor", + item: { + type: "command_execution", + deferredWork: { cursor: "old-work", itemCount: 200, lastOrdinal: 200 }, + }, + } as never, }, }, { @@ -2904,6 +2911,7 @@ describe("v2 run and attempt history", () => { runId, label: "Superseded attempt", expanded: false, + deferredWorkItemIds: ["old-work-anchor"], }); const expandedRows = deriveMessagesTimelineRows({ @@ -2920,6 +2928,52 @@ describe("v2 run and attempt history", () => { "steer-user-entry", "active-assistant-entry", ]); + const settledRows = deriveMessagesTimelineRows({ + ...common, + latestRun: { ...common.latestRun, status: "completed", completedAt: "2026-01-01T00:00:08Z" }, + timelineEntries: timelineEntries.map((entry) => + entry.kind === "message" && entry.id === "active-assistant-entry" + ? { ...entry, message: { ...entry.message, streaming: false } } + : entry, + ), + expandedRunIds: new Set([runId]), + }); + expect(settledRows.find((row) => row.kind === "attempt-fold")).toMatchObject({ + deferredWorkItemIds: ["old-work-anchor"], + }); + expect( + settledRows + .filter((row) => row.kind === "turn-fold") + .flatMap((row) => row.deferredWorkItemIds ?? []), + ).not.toContain("old-work-anchor"); + const deferredTailRows = deriveMessagesTimelineRows({ + ...common, + timelineEntries: [ + timelineEntries[0]!, + timelineEntries[2]!, + timelineEntries[1]!, + ...timelineEntries.slice(3), + ].map((entry) => ({ + ...entry, + attempt: + entry.attempt.id === supersededAttemptId + ? { ...entry.attempt, completedAt: DateTime.makeUnsafe("2026-01-01T00:00:08Z") } + : entry.attempt, + ...(entry.id === "superseded-assistant-entry" + ? { + projectedItem: { + item: { + type: "assistant_message", + ordinal: 2, + status: "completed", + completedAt: DateTime.makeUnsafe("2026-01-01T00:00:03Z"), + }, + } as never, + } + : {}), + })), + }); + expect(deferredTailRows.some((row) => row.kind === "attempt-fold")).toBe(true); }); describe("a steer that supersedes an attempt", () => { @@ -3154,6 +3208,35 @@ describe("v2 run and attempt history", () => { }); }); +it.each([ + [undefined, ["first"]], + [["first"], ["second"]], + [["first"], []], + [ + ["first", "second"], + ["second", "first"], + ], + [undefined, []], +] as const)("keeps deferred fold anchors current from %j to %j", (before, after) => { + const row: Extract = { + kind: "turn-fold", + id: "fold", + runId: RunId.make("run"), + createdAt: "2026-01-01T00:00:00Z", + label: "Worked for 1m", + expanded: true, + ...(before === undefined ? {} : { deferredWorkItemIds: before }), + }; + const previous = computeStableMessagesTimelineRows([row], { result: [], byId: new Map() }); + const nextRow = { ...row, deferredWorkItemIds: after }; + const next = computeStableMessagesTimelineRows([nextRow], previous); + const fold = next.result[0]; + expect(fold?.kind === "turn-fold" ? (fold.deferredWorkItemIds ?? []) : null).toEqual(after); + expect( + computeStableMessagesTimelineRows([{ ...nextRow, deferredWorkItemIds: [...after] }], next), + ).toBe(next); +}); + describe("streaming v2 row projection", () => { function fixture(text = "") { const source = makeStreamingTimelineFixture(text); @@ -3897,9 +3980,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"); @@ -3936,23 +4025,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"), @@ -3974,20 +4064,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), }, @@ -3995,16 +4089,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 25fe4f88383b9..19eb4fa61d842 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -1,7 +1,6 @@ export { toolGroupAction } from "@t3tools/client-runtime/work-log/presentation"; import { worktreeSetupAgentStarted } from "@t3tools/client-runtime/worktree-setup"; import { toolReadRangeLabel } from "@t3tools/client-runtime/work-log/item-detail"; -export { worktreeSetupAgentStarted } from "@t3tools/client-runtime/worktree-setup"; import { isLiveSubagentTurnItem } from "@t3tools/client-runtime/state/subagentRuntime"; import * as DateTime from "effect/DateTime"; import * as Equal from "effect/Equal"; @@ -576,6 +575,7 @@ type MessagesTimelineRowContent = } | { kind: "turn-fold"; + deferredWorkItemIds?: ReadonlyArray; id: string; createdAt: string; runId: RunId; @@ -584,6 +584,7 @@ type MessagesTimelineRowContent = } | { kind: "attempt-fold"; + deferredWorkItemIds?: ReadonlyArray; id: string; createdAt: string; runId: RunId; @@ -732,6 +733,7 @@ interface TurnFold { } interface SupersededAttemptFold { + readonly deferredWorkItemIds: ReadonlyArray; readonly runId: RunId; readonly attemptId: RunAttemptId; readonly anchorEntryId: string; @@ -754,6 +756,18 @@ function completeSupersededReplyEntryIds( ) { continue; } + const previous = lastOutputByAttemptId.get(entry.attempt.id); + const deferredEnd = + previous?.kind === "work" + ? previous.entry.projectedItem?.item.deferredWork?.lastOrdinal + : undefined; + const ordinal = + entry.kind === "work" + ? entry.entry.projectedItem?.item.ordinal + : entry.kind === "message" + ? entry.projectedItem?.item.ordinal + : undefined; + if (deferredEnd !== undefined && ordinal !== undefined && deferredEnd > ordinal) continue; lastOutputByAttemptId.set(entry.attempt.id, entry); } const replyEntryIds = new Set(); @@ -813,6 +827,11 @@ function deriveSupersededAttemptFolds( const attempt = firstEntry?.attempt; if (firstEntry === undefined || attempt === undefined) continue; foldsByAnchorEntryId.set(firstEntry.id, { + deferredWorkItemIds: entries.flatMap((entry) => + entry.kind === "work" && entry.entry.projectedItem?.item.deferredWork + ? [String(entry.entry.projectedItem.sourceItemId)] + : [], + ), runId: attempt.runId, attemptId: attempt.id, anchorEntryId: firstEntry.id, @@ -976,6 +995,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; @@ -1415,6 +1452,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( @@ -1423,6 +1461,9 @@ export function deriveMessagesTimelineRows(input: { liveSubagentEntryIds, completeSupersededReplyIds, ); + const supersededWorkItemIds = new Set( + [...supersededFoldsByAnchorEntryId.values()].flatMap((fold) => fold.deferredWorkItemIds), + ); const activeVisualResponseRunIds = deriveActiveVisualResponseRunIds({ timelineEntries: timelineEntries, unsettledRunId, @@ -1438,6 +1479,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()) { @@ -1683,9 +1725,18 @@ export function deriveMessagesTimelineRows(input: { const turnFold = foldsByAnchorEntryId.get(timelineEntry.id); if (turnFold) { + 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}`, + ...(deferredWorkItemIds.length > 0 ? { deferredWorkItemIds } : {}), createdAt: turnFold.createdAt, runId: turnFold.runId, label: turnFold.label, @@ -1701,6 +1752,7 @@ export function deriveMessagesTimelineRows(input: { if (supersededFold) { nextRows.push({ kind: "attempt-fold", + deferredWorkItemIds: supersededFold.deferredWorkItemIds, id: `attempt-fold:${supersededFold.attemptId}`, createdAt: supersededFold.createdAt, runId: supersededFold.runId, @@ -1763,7 +1815,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" || @@ -1789,6 +1841,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" || @@ -2361,14 +2415,18 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean ); } + case "attempt-fold": case "turn-fold": { const bf = b as typeof a; - return a.createdAt === bf.createdAt && a.label === bf.label && a.expanded === bf.expanded; - } - - case "attempt-fold": { - const bf = b as typeof a; - return a.createdAt === bf.createdAt && a.label === bf.label && a.expanded === bf.expanded; + const anchors = a.deferredWorkItemIds ?? []; + const nextAnchors = bf.deferredWorkItemIds ?? []; + return ( + a.createdAt === bf.createdAt && + a.label === bf.label && + a.expanded === bf.expanded && + anchors.length === nextAnchors.length && + anchors.every((id, index) => id === nextAnchors[index]) + ); } case "context-compaction": { diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 4e372406442d3..334d0d221e2e6 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -1,6 +1,7 @@ import { worktreeSetupAgentStarted } from "@t3tools/client-runtime/worktree-setup"; -import { useEffect } from "react"; import { transcriptItemId } from "./transcriptCitation.logic"; +import { threadEnvironment } from "../../state/threads"; +import { useAtomCommand } from "../../state/use-atom-command"; import type { ToolCallDiffTarget } from "../../diffPanelStore"; import { AudioPreviewUnmountContext } from "../files/AudioPreview"; import { ThreadFindTimelineContext } from "./ThreadFindProvider"; @@ -39,6 +40,8 @@ import { use, useContext, useLayoutEffect, + useEffect, + useEffectEvent, useRef, useState, type ReactNode, @@ -63,13 +66,13 @@ import ChatMarkdown from "../ChatMarkdown"; import { CircleAlertIcon } from "lucide-react"; import { ChevronDown, ChevronRight } from "lucide"; import { MorphIcon } from "~/components/MorphIcon"; +import { Skeleton } from "../ui/skeleton"; import { ExpandedImagePreview } from "./ExpandedImagePreview"; import { CHAT_TIMELINE_ANCHOR_OFFSET, readTimelinePosition, type RememberedTimelinePosition, rememberTimelinePosition, - timelineContentOverflowsViewport, } from "./timelineScrollAnchoring"; import { AssistantSelectionToolbar } from "./AssistantSelectionToolbar"; import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection"; @@ -313,8 +316,6 @@ interface MessagesTimelineProps { onIsAtEndChange: (isAtEnd: boolean) => void; /** Disable while reading history to prevent LegendList re-pinning near the bottom. */ liveFollowEnabled: boolean; - onContentOverflowChange?: (overflows: boolean) => void; - onToolOutputCollapsedAtEnd?: () => void; /** Receives the list's viewport when it mounts; the returned cleanup runs when it goes away. */ onViewportAttach?: (viewport: TimelineViewport) => () => void; /** The footer's content grew, such as a new queued turn or status line. */ @@ -415,9 +416,7 @@ const ConversationTimeline = memo(function ConversationTimeline({ onAnchorSizeChanged, contentInsetEndAdjustment, onIsAtEndChange, - onContentOverflowChange, liveFollowEnabled, - onToolOutputCollapsedAtEnd, onViewportAttach, onFooterGrow, hideEmptyPlaceholder = false, @@ -492,7 +491,6 @@ const ConversationTimeline = memo(function ConversationTimeline({ const findActive = findExpanded; const disclosurePinsEndRef = useRef(false); - const disclosureCollapsedRef = useRef(false); const pinDisclosureToEnd = () => { if (!disclosurePinsEndRef.current) return; const element = listRef.current?.getScrollableNode(); @@ -515,19 +513,12 @@ const ConversationTimeline = memo(function ConversationTimeline({ setDisclosureToggleSettling(false); disclosureSettleFrameRef.current = null; disclosureSettleSecondFrameRef.current = null; - if ( - disclosureCollapsedRef.current && - resolveTimelineIsAtEnd(listRef.current?.getState()) === true - ) { - onToolOutputCollapsedAtEnd?.(); - } }); }); }; - const suspendEndScrollMaintenanceForDisclosure = (anchorKey: string, collapsed = false) => { + const suspendEndScrollMaintenanceForDisclosure = (anchorKey: string) => { disclosureAnchorKeyRef.current = anchorKey; - disclosureCollapsedRef.current = collapsed; disclosurePinsEndRef.current = resolveTimelineIsAtEnd(listRef.current?.getState()) === true; setDisclosureToggleSettling(true); scheduleDisclosureSettle(); @@ -552,7 +543,7 @@ const ConversationTimeline = memo(function ConversationTimeline({ }); }; const onToggleWorkGroup = (groupId: string, anchorKey: string) => { - suspendEndScrollMaintenanceForDisclosure(anchorKey, expandedWorkGroupIds.has(groupId)); + suspendEndScrollMaintenanceForDisclosure(anchorKey); setExpandedWorkGroupIds((existing) => { const next = new Set(existing); if (next.has(groupId)) { @@ -905,26 +896,6 @@ const ConversationTimeline = memo(function ConversationTimeline({ size: true, shouldRestorePosition: shouldRestoreVisibleContentPosition, }; - const measureContentOverflow = () => - timelineContentOverflowsViewport(listRef.current?.getState?.(), { - composerInset: contentInsetEndAdjustment, - anchorOffset: CHAT_TIMELINE_ANCHOR_OFFSET, - }); - // Read next frame, after LegendList's layout effects settle row positions. - const contentOverflowFrameRef = useRef(null); - const cancelContentOverflowFrame = () => { - if (contentOverflowFrameRef.current !== null) { - cancelAnimationFrame(contentOverflowFrameRef.current); - contentOverflowFrameRef.current = null; - } - }; - const reportContentOverflow = () => { - if (!onContentOverflowChange || contentOverflowFrameRef.current !== null) return; - contentOverflowFrameRef.current = requestAnimationFrame(() => { - contentOverflowFrameRef.current = null; - onContentOverflowChange(measureContentOverflow()); - }); - }; const refreshMinimap = useStableCallback(() => { const state = listRef.current?.getState?.(); @@ -1032,7 +1003,6 @@ const ConversationTimeline = memo(function ConversationTimeline({ if (isAtEnd !== undefined && !citationPositioning) { onIsAtEndChange(isAtEnd); } - reportContentOverflow(); scheduleMinimapRefresh(); }; @@ -1041,7 +1011,6 @@ const ConversationTimeline = memo(function ConversationTimeline({ queueMicrotask(pinDisclosureToEnd); scheduleDisclosureSettle(); } - reportContentOverflow(); scheduleMinimapRefresh(); }; @@ -1235,8 +1204,7 @@ const ConversationTimeline = memo(function ConversationTimeline({ const canvas = useChatCanvas(); const registerTimeline = canvas?.registerTimeline; - // Stable so the list attaches once per mount. A re-attach reports no overflow for a frame, - // which flips the resting composer, resizes it, and re-renders this list again. + // Stable so the list attaches once per mount, not on every render. const setTimelineList = useStableCallback((list: LegendListRef | null) => { listRef.current = list; const scrollNode: HTMLElement | null = list?.getScrollableNode() ?? null; @@ -1268,14 +1236,12 @@ const ConversationTimeline = memo(function ConversationTimeline({ return () => { stopListeningToContentSize?.(); stopRememberingRelease?.(); - onContentOverflowChange?.(false); if (contentSizeFrame !== null) cancelAnimationFrame(contentSizeFrame); detachViewport?.(); viewport?.dispose(); if (viewportRef.current === viewport) viewportRef.current = null; // Refresh frames only read the list; disclosure frames still run so the // settle flag clears. - cancelContentOverflowFrame(); if (minimapRefreshFrameRef.current !== null) { cancelAnimationFrame(minimapRefreshFrameRef.current); minimapRefreshFrameRef.current = null; @@ -1345,7 +1311,7 @@ const ConversationTimeline = memo(function ConversationTimeline({ >
@@ -1365,10 +1331,7 @@ const ConversationTimeline = memo(function ConversationTimeline({ {unmatchedPendingApprovals.size > 0 ? (
-
+
{[...unmatchedPendingApprovals.values()].map((approval) => ( @@ -1468,8 +1431,6 @@ const ConversationTimeline = memo(function ConversationTimeline({ maintainScrollAtEndThreshold={1} onScroll={handleScroll} onItemSizeChanged={handleItemSizeChanged} - // A viewport resize changes how much of the content fits. - onLayout={reportContentOverflow} className={cn( "messages-timeline-scroll scrollbar-gutter-both h-full min-h-0 overflow-x-hidden overscroll-y-contain [overflow-anchor:none]", topFadeEnabled && "topbar-scroll-fade", @@ -1628,7 +1589,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time {row.kind === "thinking" ? : null} ) : null} - {row.kind === "turn-fold" ? : null} + {row.kind === "turn-fold" ? : null} {row.kind === "attempt-fold" ? : null} {row.kind === "context-compaction" ? : null} {row.kind === "message" && row.message.role === "user" ? : null} @@ -1743,48 +1704,123 @@ function ContextCompactionTimelineRow({ ); } +function useDeferredWorkLoading( + row: { expanded: boolean; deferredWorkItemIds?: ReadonlyArray }, + onToggle: () => void, +) { + const ctx = use(TimelineRowCtx); + const loadWork = useAtomCommand(threadEnvironment.loadEarlierHistory, { reportFailure: false }); + const ref = parseScopedThreadKey(ctx.routeThreadKey); + const environmentId = ref?.environmentId; + const threadId = ref?.threadId; + const anchorsKey = row.deferredWorkItemIds?.join("\n") ?? ""; + const [failed, setFailed] = useState(false); + const load = () => { + if (!anchorsKey || environmentId === undefined || threadId === undefined) return; + void Promise.all( + anchorsKey + .split("\n") + .map((workAnchorId) => loadWork({ environmentId, input: { threadId, workAnchorId } })), + ).then((results) => + setFailed( + results.some((result) => result._tag === "Failure" || result.value._tag === "error"), + ), + ); + }; + const loadOnExpand = useEffectEvent(load); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Restored expanded folds load their deferred server history on mount. + useEffect(() => { + if (row.expanded && anchorsKey && environmentId && threadId) loadOnExpand(); + }, [row.expanded, anchorsKey, environmentId, threadId]); + const error = anchorsKey && failed ? "Couldn't load work. Click to retry." : null; + const loading = ref !== null && row.expanded && anchorsKey !== "" && !failed; + const toggle = () => { + setFailed(false); + if (error && row.expanded) load(); + else onToggle(); + }; + + return { loading, error, toggle }; +} + +function WorkHistorySkeleton() { + return ( +
+ {[0, 1, 2].map((index) => ( + + ))} +
+ ); +} + function TurnFoldTimelineRow({ row }: { row: Extract }) { const ctx = use(TimelineRowCtx); + const { loading, error, toggle } = useDeferredWorkLoading(row, () => + ctx.onToggleTurnFold(row.runId), + ); return ( -
- - +
+
+ + +
+ {loading ? : null}
); } function AttemptFoldTimelineRow({ row }: { row: Extract }) { const ctx = use(TimelineRowCtx); + const { loading, error, toggle } = useDeferredWorkLoading(row, () => + ctx.onToggleAttemptFold(row.attemptId), + ); return ( - +
+ + {loading ? : null} +
); } diff --git a/apps/web/src/components/chat/ModelPickerContent.tsx b/apps/web/src/components/chat/ModelPickerContent.tsx index b1f2deb0c5641..f59307546964f 100644 --- a/apps/web/src/components/chat/ModelPickerContent.tsx +++ b/apps/web/src/components/chat/ModelPickerContent.tsx @@ -40,6 +40,7 @@ import { } from "../ui/combobox"; import { ModelEsque } from "./providerIconUtils"; import { isCommandPaletteOpen } from "../../commandPaletteBus"; +import { registerModelPickerContent } from "../../modelPickerVisibility"; import { primaryServerKeybindingsAtom } from "../../state/server"; import { modelPickerJumpCommandForIndex, @@ -385,6 +386,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/ProviderAccountPicker.tsx b/apps/web/src/components/chat/ProviderAccountPicker.tsx index 2b50ddf3ce43c..99cd7a7fd22fa 100644 --- a/apps/web/src/components/chat/ProviderAccountPicker.tsx +++ b/apps/web/src/components/chat/ProviderAccountPicker.tsx @@ -3,11 +3,7 @@ import { memo } from "react"; import { CheckIcon, GitForkIcon } from "lucide-react"; import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; -import { - ComposerControl, - ComposerControlChevron, - type ComposerControlSize, -} from "./ComposerControl"; +import { ComposerControl, type ComposerControlSize } from "./ComposerControl"; import { useComposerMenuProps } from "./composerEventScope"; import { useComposerMenuState } from "./useComposerMenuState"; import { ProviderInstanceIcon } from "./ProviderInstanceIcon"; @@ -15,15 +11,9 @@ import { shouldShowInstanceBadge, type ProviderInstanceEntry } from "../../provi import { cn } from "~/lib/utils"; /** - * Composer control for switching accounts of the active provider. - * Rendered when that provider has more than one enabled account. With fewer - * than two switchable `accounts` (a thread locked to one account's home) it - * becomes a plain label — icon and name, no control chrome — so the thread - * still shows which account it runs on. - * - * Once a thread has started, accounts it cannot switch to in place can still - * be listed with `requiresFork`: choosing one calls `onAccountChange`, which - * forks the chat into a new tab on that account. + * Switches accounts of the active provider. With fewer than two switchable accounts it is a + * plain label, so the thread still shows its account. `requiresFork` lists accounts a started + * thread cannot switch to in place; choosing one forks the chat into a new tab. */ export const ProviderAccountPicker = memo(function ProviderAccountPicker(props: { activeInstanceId: ProviderInstanceId; @@ -68,7 +58,6 @@ export const ProviderAccountPicker = memo(function ProviderAccountPicker(props: {triggerLabel} - {props.accounts.length > 1 ? : null} ); diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx index c118cdb98c9b0..8bfa4078967df 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.tsx @@ -15,11 +15,7 @@ import { ChatGptSharingControl } from "./ChatGptSharingControl"; import { ProviderInstanceIcon } from "./ProviderInstanceIcon"; import { ModelEsque, getDisplayModelName, getTriggerDisplayModelName } from "./providerIconUtils"; import { type ProviderInstanceEntry } from "../../providerInstances"; -import { - ComposerControl, - ComposerControlChevron, - type ComposerControlSize, -} from "./ComposerControl"; +import { ComposerControl, type ComposerControlSize } from "./ComposerControl"; import { useComposerMenuProps } from "./composerEventScope"; import { shortcutLabelForCommand } from "../../keybindings"; @@ -214,7 +210,11 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { className={cn("flex min-w-0 flex-1 items-center", size === "xs" ? "gap-1" : "gap-1.5")} > {selectedEntries && props.triggerLabel === undefined ? ( - - Promise; onDeleteProjectScript: (scriptId: string) => Promise; + /** Set only while the Labs usage readout is on. */ + usage?: ThreadDetailsUsageInput | undefined; } export function ThreadDetailsPanel(props: ThreadDetailsPanelProps) { @@ -216,6 +219,8 @@ export function ThreadDetailsPanel(props: ThreadDetailsPanelProps) { threadId={props.threadId} /> ) : null} + + {props.usage ? : null} )} diff --git a/apps/web/src/components/chat/ThreadDetailsUsage.tsx b/apps/web/src/components/chat/ThreadDetailsUsage.tsx new file mode 100644 index 0000000000000..6a9adadfc7200 --- /dev/null +++ b/apps/web/src/components/chat/ThreadDetailsUsage.tsx @@ -0,0 +1,228 @@ +import type { + ProviderInstanceId, + ServerProvider, + ServerProviderUsageWindow, + UsageLimitSourceSnapshots, +} from "@t3tools/contracts"; +import { + collectProviderUsageLimits, + formatDuration, + type LimitPace, + limitsNotice, + paceOf, + remainingPercent, +} from "@t3tools/shared/usageLimits"; +import type { ReactNode } from "react"; + +import { usePrimarySettings } from "../../hooks/useSettings"; +import { useNowMinute } from "../../hooks/useNowMinute"; +import { type ContextWindowSnapshot, formatContextWindowTokens } from "../../lib/contextWindow"; +import { cn } from "../../lib/utils"; +import { formatUpcomingTimestamp } from "../../timestampFormat"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { ThreadDetailsSection } from "./ThreadDetailsSection"; + +const PACE_LABEL: Record = { + 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; +} + +/** + * Labs readout of 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/ThreadRelationshipsControl.tsx b/apps/web/src/components/chat/ThreadRelationshipsControl.tsx index 47fc3037eee90..4992d8e5a6f37 100644 --- a/apps/web/src/components/chat/ThreadRelationshipsControl.tsx +++ b/apps/web/src/components/chat/ThreadRelationshipsControl.tsx @@ -40,6 +40,7 @@ import { isProviderNativeSubagentThread, NodeId, type EnvironmentId, + type OrchestrationV2DelegatedFrom, type OrchestrationV2RemoteTaskChild, type OrchestrationV2Subagent, type OrchestrationV2ThreadShell, @@ -77,6 +78,7 @@ import { useRightPanelStore } from "../../rightPanelStore"; import { buildThreadRouteParams } from "../../threadRoutes"; import { useProjects, + useQuietThreadShell, useThreadProjection, useThreadRelationshipShells, } from "../../state/entities"; @@ -281,7 +283,8 @@ function RemoteSubagentRow(props: { } > + + + void navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(parentRef), + }) + } + /> + } + > + + + + {title} + + + {label ? ( + on {label} + ) : null} + + + {environment === null + ? `Delegated from ${label ?? "a linked environment"}. Connect to it to open the parent thread.` + : "Open parent agent"} + + + + ); +} + export function ThreadRelationshipsPanel(props: { readonly environmentId: EnvironmentId; readonly threadId: ThreadId; @@ -469,7 +528,15 @@ export function ThreadRelationshipsPanel(props: { (agent) => agent.childThreadId === null && agent.status === "running", ).length ?? 0) + active.filter(({ edge }) => edge.status === "running").length; - if (relationshipRows.length === 0 && runningCount === 0 && remoteSubagents.length === 0) { + // A linked environment's agent may have delegated this thread from there. + const delegatedFrom = currentThread?.delegatedFrom; + + if ( + relationshipRows.length === 0 && + runningCount === 0 && + remoteSubagents.length === 0 && + delegatedFrom === undefined + ) { return null; } @@ -544,7 +611,13 @@ export function ThreadRelationshipsPanel(props: { headingId="thread-details-lineage-heading" title={runningCount > 0 ? `Lineage · ${runningCount} running` : "Lineage"} // A lone "Previous agents" row names itself; the heading returns for status or actions. - showHeading={runningCount > 0 || canDetach || related.length > 0 || active.length > 0} + showHeading={ + runningCount > 0 || + canDetach || + related.length > 0 || + active.length > 0 || + delegatedFrom !== undefined + } data-thread-relationships-panel actions={ agentRowCount > 0 || runningCount > 0 || canDetach ? ( @@ -595,6 +668,14 @@ export function ThreadRelationshipsPanel(props: { ) : null } > + {delegatedFrom === undefined ? null : ( + {}}> + + + )} {showAgentToolbar ? ( ))} diff --git a/apps/web/src/components/chat/TraitsPicker.test.ts b/apps/web/src/components/chat/TraitsPicker.test.ts index 4efa60cf5b63f..3f6457e1d3772 100644 --- a/apps/web/src/components/chat/TraitsPicker.test.ts +++ b/apps/web/src/components/chat/TraitsPicker.test.ts @@ -68,16 +68,16 @@ function display(descriptors: ReadonlyArray) { describe("buildTraitsTriggerDisplay", () => { it("omits fast mode from the label entirely when it is off", () => { - expect(display([EFFORT, fastModeDescriptor(false), CONTEXT_WINDOW])).toEqual({ + expect(display([EFFORT, fastModeDescriptor(false), CONTEXT_WINDOW])).toMatchObject({ label: "High · 1M", }); }); it("pairs fast mode with reasoning before the context window", () => { - expect(display([EFFORT, fastModeDescriptor(true), CONTEXT_WINDOW])).toEqual({ + expect(display([EFFORT, fastModeDescriptor(true), CONTEXT_WINDOW])).toMatchObject({ label: "High Fast · 1M", }); - expect(display([EFFORT, CONTEXT_WINDOW, fastModeDescriptor(true)])).toEqual({ + expect(display([EFFORT, CONTEXT_WINDOW, fastModeDescriptor(true)])).toMatchObject({ label: "High Fast · 1M", }); }); @@ -98,7 +98,7 @@ describe("buildTraitsTriggerDisplay", () => { primarySelectDescriptorId: "profile", ultrathinkPromptControlled: false, }), - ).toEqual({ label: "Balanced · 1M · High Fast" }); + ).toMatchObject({ label: "Balanced · 1M · High Fast" }); } expect( buildTraitsTriggerDisplay({ @@ -107,7 +107,7 @@ describe("buildTraitsTriggerDisplay", () => { primarySelectDescriptorId: CONTEXT_WINDOW.id, ultrathinkPromptControlled: false, }), - ).toEqual({ label: "1M · High Ultrafast" }); + ).toMatchObject({ label: "1M · High Ultrafast" }); }, ); @@ -124,7 +124,7 @@ describe("buildTraitsTriggerDisplay", () => { primarySelectDescriptorId: CONTEXT_WINDOW.id, ultrathinkPromptControlled: false, }), - ).toEqual({ label: "1M · High Fast · Thinking On" }); + ).toMatchObject({ label: "1M · High Fast · Thinking On" }); }); it.each([true, false])("keeps boolean thinking %s independent of Fast", (currentValue) => { @@ -135,15 +135,21 @@ describe("buildTraitsTriggerDisplay", () => { currentValue, }; const label = `Thinking ${currentValue ? "On" : "Off"}`; - expect(display([thinking, fastModeDescriptor(true)])).toEqual({ label: `${label} · Fast` }); - expect(display([thinking, { ...EFFORT, id: "reasoning" }, fastModeDescriptor(true)])).toEqual({ + expect(display([thinking, fastModeDescriptor(true)])).toMatchObject({ + label: `${label} · Fast`, + }); + expect( + display([thinking, { ...EFFORT, id: "reasoning" }, fastModeDescriptor(true)]), + ).toMatchObject({ label: `${label} · High Fast`, }); - expect(display([thinking, fastModeDescriptor(false)])).toEqual({ label }); + expect(display([thinking, fastModeDescriptor(false)])).toMatchObject({ label }); }); it("keeps speed separate when there is no reasoning descriptor", () => { - expect(display([CONTEXT_WINDOW, fastModeDescriptor(true)])).toEqual({ label: "1M · Fast" }); + expect(display([CONTEXT_WINDOW, fastModeDescriptor(true)])).toMatchObject({ + label: "1M · Fast", + }); }); it.each(["Low", "Medium", "High", "Extra High", "Max", "Ultra"])( @@ -160,18 +166,20 @@ describe("buildTraitsTriggerDisplay", () => { primarySelectDescriptorId: EFFORT.id, ultrathinkPromptControlled: false, }), - ).toEqual({ label: `${label} Fast` }); + ).toMatchObject({ label: `${label} Fast` }); } }, ); it("treats Codex standard and fast service tiers as fast mode states", () => { - expect(display([EFFORT, serviceTierDescriptor("default")])).toEqual({ label: "High" }); - expect(display([EFFORT, serviceTierDescriptor("priority")])).toEqual({ label: "High Fast" }); + expect(display([EFFORT, serviceTierDescriptor("default")])).toMatchObject({ label: "High" }); + expect(display([EFFORT, serviceTierDescriptor("priority")])).toMatchObject({ + label: "High Fast", + }); }); it("keeps Codex Ultrafast distinct from Fast", () => { - expect(display([EFFORT, serviceTierDescriptor("ultrafast")])).toEqual({ + expect(display([EFFORT, serviceTierDescriptor("ultrafast")])).toMatchObject({ label: "High Ultrafast", }); }); @@ -183,11 +191,13 @@ describe("buildTraitsTriggerDisplay", () => { EFFORT, { ...descriptor, options: descriptor.options.filter(({ id }) => id !== "priority") }, ]), - ).toEqual({ label: "High Ultrafast" }); + ).toMatchObject({ label: "High Ultrafast" }); }); it("keeps other Codex service tiers in the label", () => { - expect(display([EFFORT, serviceTierDescriptor("flex")])).toEqual({ label: "High · Flex" }); + expect(display([EFFORT, serviceTierDescriptor("flex")])).toMatchObject({ + label: "High · Flex", + }); }); it("keeps Standard as text for models without speed tiers", () => { @@ -196,17 +206,17 @@ describe("buildTraitsTriggerDisplay", () => { ...descriptor, options: descriptor.options.filter(({ id }) => id === "default" || id === "flex"), }; - expect(display([EFFORT, nonSpeedDescriptor])).toEqual({ label: "High · Standard" }); - expect(display([nonSpeedDescriptor])).toEqual({ label: "Standard" }); + expect(display([EFFORT, nonSpeedDescriptor])).toMatchObject({ label: "High · Standard" }); + expect(display([nonSpeedDescriptor])).toMatchObject({ label: "Standard" }); }); it("keeps the Codex service tier readable when it is the only trait", () => { - expect(display([serviceTierDescriptor("default")])).toEqual({ label: "Standard" }); - expect(display([serviceTierDescriptor("priority")])).toEqual({ label: "Fast" }); + expect(display([serviceTierDescriptor("default")])).toMatchObject({ label: "Standard" }); + expect(display([serviceTierDescriptor("priority")])).toMatchObject({ label: "Fast" }); }); it("keeps Ultrafast readable when it is the only trait", () => { - expect(display([serviceTierDescriptor("ultrafast")])).toEqual({ label: "Ultrafast" }); + expect(display([serviceTierDescriptor("ultrafast")])).toMatchObject({ label: "Ultrafast" }); }); it("keeps the subscription limit guard out of the trigger so thinking still shows", () => { @@ -222,9 +232,9 @@ describe("buildTraitsTriggerDisplay", () => { type: "boolean" as const, currentValue: true, }; - expect(display([EFFORT, guard])).toEqual({ label: "High" }); - expect(display([thinking, guard])).toEqual({ label: "Thinking On" }); - expect(display([{ ...guard, currentValue: true }])).toEqual({ label: "" }); + expect(display([EFFORT, guard])).toMatchObject({ label: "High" }); + expect(display([thinking, guard])).toMatchObject({ label: "Thinking On" }); + expect(display([{ ...guard, currentValue: true }])).toMatchObject({ label: "" }); }); it("keeps non-fastMode booleans as text labels", () => { @@ -234,16 +244,16 @@ describe("buildTraitsTriggerDisplay", () => { type: "boolean", currentValue: true, }; - expect(display([EFFORT, thinking])).toEqual({ label: "High · Thinking On" }); + expect(display([EFFORT, thinking])).toMatchObject({ label: "High · Thinking On" }); }); it("falls back to a text label when fast mode is the only trait", () => { - expect(display([fastModeDescriptor(true)])).toEqual({ label: "Fast" }); - expect(display([fastModeDescriptor(false)])).toEqual({ label: "Normal" }); + expect(display([fastModeDescriptor(true)])).toMatchObject({ label: "Fast" }); + expect(display([fastModeDescriptor(false)])).toMatchObject({ label: "Normal" }); }); it("does not add Fast to a model without a speed option", () => { - expect(display([EFFORT, CONTEXT_WINDOW])).toEqual({ label: "High · 1M" }); + expect(display([EFFORT, CONTEXT_WINDOW])).toMatchObject({ label: "High · 1M" }); }); it("stays blank when descriptors resolve to no label and there is no fast mode", () => { @@ -256,7 +266,7 @@ describe("buildTraitsTriggerDisplay", () => { { id: "high", label: "High" }, ], }; - expect(display([unresolved])).toEqual({ label: "" }); + expect(display([unresolved])).toMatchObject({ label: "" }); }); it("still renders prompt-controlled ultrathink with Fast", () => { @@ -267,7 +277,7 @@ describe("buildTraitsTriggerDisplay", () => { primarySelectDescriptorId: "reasoningEffort", ultrathinkPromptControlled: true, }), - ).toEqual({ label: "Ultrathink Fast" }); + ).toMatchObject({ label: "Ultrathink Fast" }); }); }); diff --git a/apps/web/src/components/chat/TraitsPicker.tsx b/apps/web/src/components/chat/TraitsPicker.tsx index 8c6b20ddc30e7..3c969f21942b3 100644 --- a/apps/web/src/components/chat/TraitsPicker.tsx +++ b/apps/web/src/components/chat/TraitsPicker.tsx @@ -16,6 +16,7 @@ import { type ScopedThreadRef, type ServerProviderModel, } from "@t3tools/contracts"; +import type { EffortDisplay } from "@t3tools/contracts/settings"; import { buildProviderOptionSelectionsFromDescriptors, getProviderOptionCurrentLabel, @@ -23,8 +24,8 @@ import { isClaudeUltrathinkPrompt, normalizeModelSlug, } from "@t3tools/shared/model"; -import { memo } from "react"; -import { GaugeIcon } from "lucide-react"; +import { memo, type ReactNode, useRef } from "react"; +import { GaugeIcon, ZapIcon } from "lucide-react"; import { Menu, MenuGroup, @@ -39,14 +40,10 @@ import { getProviderModelCapabilities } from "../../providerModels"; import { cn } from "~/lib/utils"; import { Badge } from "../ui/badge"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; -import { - ComposerControl, - ComposerControlChevron, - ComposerControlIcon, - type ComposerControlSize, -} from "./ComposerControl"; -import { useComposerMenuProps } from "./composerEventScope"; +import { ComposerControl, ComposerControlIcon, type ComposerControlSize } from "./ComposerControl"; +import { focusRequestedPermission, useComposerMenuProps } from "./composerEventScope"; import { useComposerMenuState } from "./useComposerMenuState"; +import { EffortGlyph, resolveEffortGlyph } from "./EffortGlyph"; type ProviderOptions = ReadonlyArray; @@ -335,6 +332,14 @@ interface TraitsMenuContentProps { descriptorIds?: ReadonlyArray | undefined; triggerClassName?: string; isComposerOwned?: boolean; + /** Composer chips name the effort level unless the user picked bars. */ + effortDisplay?: EffortDisplay; + /** Extra section shown below the traits in the picker popup (Permissions). */ + extraMenuContent?: ReactNode; + /** This trigger also answers composer.mode (while the permissions chip is hidden). */ + extraMenuOwnsModeShortcut?: boolean; + /** Extra tooltip text naming what else the menu holds. */ + extraMenuHint?: string; } export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ @@ -522,6 +527,24 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ ); }); +/** One bolt for Fast. Ultrafast adds an outlined echo behind it, cut away by the surface color. */ +function FastBolt(props: { ultra: boolean }) { + if (!props.ultra) { + return ( +
+ {usage.used !== null && usage.limit !== null ? ( +
  • + +
  • + ) : null} )} diff --git a/apps/web/src/components/clerk/T3ConnectAccountPages.tsx b/apps/web/src/components/clerk/T3ConnectAccountPages.tsx index 85dcc7dd255c4..3e804bdf68d61 100644 --- a/apps/web/src/components/clerk/T3ConnectAccountPages.tsx +++ b/apps/web/src/components/clerk/T3ConnectAccountPages.tsx @@ -28,22 +28,24 @@ export const T3_CONNECT_ACCOUNT_PAGES = [ }, ] as const; +export type T3ConnectAccountPageUrl = (typeof T3_CONNECT_ACCOUNT_PAGES)[number]["url"]; + type PortalTargets = Readonly>; /** Keep portals rendered while the modal can open; Clerk owns the custom-page DOM nodes. */ export function useT3ConnectAccountPage(): { - readonly open: (() => void) | null; + readonly open: ((page?: T3ConnectAccountPageUrl) => void) | null; readonly portals: ReactNode; } { const clerk = useClerk(); const { isSignedIn } = useAuth(); const [targets, setTargets] = useState({}); - const open = () => { + const open = (page: T3ConnectAccountPageUrl = "t3-connect") => { const setTarget = (key: string, element: HTMLDivElement | undefined) => setTargets((current) => ({ ...current, [key]: element })); clerk.openUserProfile({ - __experimental_startPath: "/t3-connect", + __experimental_startPath: `/${page}`, customPages: T3_CONNECT_ACCOUNT_PAGES.map((page) => ({ label: page.label, url: page.url, diff --git a/apps/web/src/components/clerk/T3ConnectUserProfilePage.tsx b/apps/web/src/components/clerk/T3ConnectUserProfilePage.tsx index d0342005e6fd2..be59127723c2d 100644 --- a/apps/web/src/components/clerk/T3ConnectUserProfilePage.tsx +++ b/apps/web/src/components/clerk/T3ConnectUserProfilePage.tsx @@ -11,9 +11,11 @@ import { useRef, useState } from "react"; import { deregisterManagedRelayEnvironmentCommand, useManagedRelayEnvironments, + useManagedTunnelUsage, } from "../../cloud/managedRelayState"; import { useAtomCommand } from "../../state/use-atom-command"; -import { Button } from "../ui/button"; +import { T3CHAT_SUBSCRIPTION_URL, TunnelUsageMeter, tunnelUpgradeCopy } from "../cloud/TunnelUsage"; +import { Button, InlineButton } from "../ui/button"; import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible"; import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "../ui/empty"; import { toastManager } from "../ui/toast"; @@ -22,6 +24,7 @@ import { ClerkUserProfileRefreshButton, ClerkUserProfileRow, } from "./ClerkUserProfilePage"; +import { useT3ChatLink } from "./useT3ChatLink"; const linkedAtFormatter = new Intl.DateTimeFormat(undefined, { dateStyle: "medium" }); @@ -110,6 +113,53 @@ function T3ConnectEnvironmentRow(props: { ); } +function T3ConnectTunnelUsage() { + const usage = useManagedTunnelUsage(); + const t3Chat = useT3ChatLink(); + if (usage.used === null || usage.limit === null) return null; + return ( +
    + ); +} + export function T3ConnectUserProfilePage() { const environmentsState = useManagedRelayEnvironments(); const deregisterEnvironment = useAtomCommand(deregisterManagedRelayEnvironmentCommand, { @@ -188,6 +238,8 @@ export function T3ConnectUserProfilePage() { (environment) => removedEnvironmentLinkedAt.get(environment.environmentId) !== environment.linkedAt, ); + const usage = useManagedTunnelUsage(); + const refreshAll = usage.refresh; const isInitialLoad = !environmentsState.accountId || (environmentsState.data === null && !environmentsState.error); @@ -199,11 +251,12 @@ export function T3ConnectUserProfilePage() { } >
    + {environmentsState.error ? (

    diff --git a/apps/web/src/components/clerk/useT3ChatLink.ts b/apps/web/src/components/clerk/useT3ChatLink.ts new file mode 100644 index 0000000000000..66d2267c62682 --- /dev/null +++ b/apps/web/src/components/clerk/useT3ChatLink.ts @@ -0,0 +1,76 @@ +import { findErrorTraceId } from "@t3tools/client-runtime/errors"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import { useState } from "react"; + +import { + createManagedRelayT3ChatLinkRequestCommand, + unlinkManagedRelayT3ChatCommand, + useManagedRelayT3ChatLink, +} from "../../cloud/managedRelayState"; +import { readLocalApi } from "../../localApi"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { toastManager } from "../ui/toast"; + +function reportFailure(title: string, cause: unknown) { + const traceId = findErrorTraceId(cause); + toastManager.add({ + type: "error", + title, + description: cause instanceof Error ? cause.message : "Try again.", + data: traceId + ? { + secondaryActionProps: { + children: "Copy trace ID", + onClick: () => void navigator.clipboard?.writeText(traceId), + }, + } + : undefined, + }); +} + +/** Starts and removes the T3 Chat link that raises the T3 Connect tunnel cap. */ +export function useT3ChatLink() { + const linkState = useManagedRelayT3ChatLink(); + const createLinkRequest = useAtomCommand(createManagedRelayT3ChatLinkRequestCommand, { + reportFailure: false, + }); + const unlinkCommand = useAtomCommand(unlinkManagedRelayT3ChatCommand, { reportFailure: false }); + const [pending, setPending] = useState<"link" | "unlink" | null>(null); + // Kept so a blocked popup still leaves the user a link to open. + const [linkUrl, setLinkUrl] = useState(null); + + const link = async () => { + const accountId = linkState.accountId; + if (!accountId || pending) return; + setPending("link"); + const result = await createLinkRequest({ accountId }); + if (result._tag === "Success") { + setLinkUrl(result.value); + await readLocalApi() + ?.shell.openExternal(result.value) + .catch(() => undefined); + } else if (!isAtomCommandInterrupted(result)) { + reportFailure("Could not start linking", squashAtomCommandFailure(result)); + } + setPending(null); + }; + + const unlink = async () => { + const accountId = linkState.accountId; + if (!accountId || pending) return; + setPending("unlink"); + const result = await unlinkCommand({ accountId }); + if (result._tag === "Success") { + setLinkUrl(null); + linkState.refresh(); + } else if (!isAtomCommandInterrupted(result)) { + reportFailure("Could not unlink T3 Chat", squashAtomCommandFailure(result)); + } + setPending(null); + }; + + return { linkState, pending, linkUrl, link, unlink }; +} diff --git a/apps/web/src/components/cloud/ConnectOnboardingDialog.tsx b/apps/web/src/components/cloud/ConnectOnboardingDialog.tsx index 8daf67714cc45..2e6bb927d69dc 100644 --- a/apps/web/src/components/cloud/ConnectOnboardingDialog.tsx +++ b/apps/web/src/components/cloud/ConnectOnboardingDialog.tsx @@ -9,6 +9,7 @@ import { ConnectOnboardingOptOutSchema, EMPTY_CONNECT_ONBOARDING_OPT_OUT_STATE, } from "~/cloud/connectOnboarding"; +import { useManagedTunnelUsage } from "~/cloud/managedRelayState"; import { hasCloudPublicConfig } from "~/cloud/publicConfig"; import { useCloudLinkController } from "~/cloud/useCloudLinkController"; import { useLocalStorage } from "~/hooks/useLocalStorage"; @@ -19,7 +20,12 @@ import { usePrimaryEnvironmentId, } from "~/state/environments"; import { environmentSession, readEnvironmentScope, useEnvironmentScope } from "~/state/session"; +import { + type T3ConnectAccountPageUrl, + useT3ConnectAccountPage, +} from "../clerk/T3ConnectAccountPages"; import { CloudEnvironmentConnectRows } from "./CloudEnvironmentConnectList"; +import { TunnelLimitAlert } from "./TunnelUsage"; import { Button } from "../ui/button"; import { Checkbox } from "../ui/checkbox"; import { Dialog } from "../ui/dialog"; @@ -63,6 +69,7 @@ function ConfiguredConnectOnboardingDialog() { primaryEnvironmentId === null ? environmentsReady : primarySessionState._tag !== "Initial"; const controller = useCloudLinkController(); + const accountPage = useT3ConnectAccountPage(); const showPublishStep = canManageRelay && controller.linkState.target !== null; const steps: ReadonlyArray = showPublishStep ? ["publish", "devices"] @@ -180,6 +187,15 @@ function ConfiguredConnectOnboardingDialog() { setStep("devices"); }; + const openAccountPage = accountPage.open; + // The account page is a Clerk modal, so close this dialog before it opens. + const leaveForAccountPage = openAccountPage + ? (page: T3ConnectAccountPageUrl) => { + complete(); + openAccountPage(page); + } + : null; + return (

    { if ( primaryEnvironmentId !== null && @@ -274,6 +292,8 @@ function ConfiguredConnectOnboardingDialog() { )} + {/* Base UI renders Root children while closed, so this keeps the account page mounted. */} + {accountPage.portals} ); } @@ -288,6 +308,8 @@ function PublishStep({ publishAgentActivity, disabled, operationError, + tunnelLimit, + onOpenAccountPage, onExposeEnvironmentChange, onPublishAgentActivityChange, }: { @@ -295,6 +317,8 @@ function PublishStep({ readonly publishAgentActivity: boolean; readonly disabled: boolean; readonly operationError: string | null; + readonly tunnelLimit: number | null; + readonly onOpenAccountPage: ((page: T3ConnectAccountPageUrl) => void) | null; readonly onExposeEnvironmentChange: (enabled: boolean) => void; readonly onPublishAgentActivityChange: (enabled: boolean) => void; }) { @@ -317,10 +341,28 @@ function PublishStep({ />
    {operationError ?

    {operationError}

    : null} + {tunnelLimit !== null ? ( + + ) : null}
    ); } +function OnboardingTunnelLimitAlert({ + onOpenAccountPage, +}: { + readonly onOpenAccountPage: ((page: T3ConnectAccountPageUrl) => void) | null; +}) { + const { upgrade } = useManagedTunnelUsage(); + return ( + onOpenAccountPage("t3-chat") : null} + onManageTunnels={onOpenAccountPage ? () => onOpenAccountPage("t3-connect") : null} + /> + ); +} + function OnboardingToggleRow({ title, description, diff --git a/apps/web/src/components/cloud/TunnelUsage.tsx b/apps/web/src/components/cloud/TunnelUsage.tsx new file mode 100644 index 0000000000000..0b392f77b5416 --- /dev/null +++ b/apps/web/src/components/cloud/TunnelUsage.tsx @@ -0,0 +1,118 @@ +import { T3CHAT_PLAN_TUNNEL_LIMITS } from "@t3tools/contracts/relay"; +import { TriangleAlertIcon } from "lucide-react"; +import type { ReactNode } from "react"; + +import type { ManagedTunnelUpgrade } from "../../cloud/managedRelayState"; +import { cn } from "../../lib/utils"; +import { Alert, AlertAction, AlertDescription, AlertTitle } from "../ui/alert"; +import { Button } from "../ui/button"; + +// Above this, one slot per tunnel is too thin to read, so the bar fills instead. +const MAX_SEGMENTS = 10; + +export const T3CHAT_SUBSCRIPTION_URL = "https://t3.chat/settings/subscription"; + +export function tunnelUpgradeCopy(upgrade: Exclude): string { + const { pro, premier } = T3CHAT_PLAN_TUNNEL_LIMITS; + switch (upgrade) { + case "link": + return `Link T3 Chat Pro for up to ${pro} tunnels, or Premier for up to ${premier}.`; + case "pro": + return `Upgrade to T3 Chat Pro for up to ${pro} tunnels, or Premier for up to ${premier}.`; + case "premier": + return `Upgrade to T3 Chat Premier for up to ${premier} tunnels.`; + } +} + +/** "2 of 3 tunnels in use" with one bar slot per tunnel. */ +export function TunnelUsageMeter({ + used, + limit, + action, + className, +}: { + readonly used: number; + readonly limit: number; + readonly action?: ReactNode; + readonly className?: string; +}) { + const full = used >= limit; + const fill = full ? "bg-warning" : "bg-foreground/72"; + return ( +
    +
    + + {used} of {limit} tunnels in use + + {action} +
    +
    + {limit <= MAX_SEGMENTS ? ( + Array.from({ length: limit }, (_, index) => ( + + )) + ) : ( + + + + )} +
    +
    + ); +} + +/** Shown when the relay refuses a new tunnel because the account is at its cap. */ +export function TunnelLimitAlert({ + upgrade, + onLinkT3Chat, + onManageTunnels, +}: { + readonly upgrade: ManagedTunnelUpgrade; + readonly onLinkT3Chat: (() => void) | null; + readonly onManageTunnels: (() => void) | null; +}) { + const action = + upgrade === "link" && onLinkT3Chat ? ( + + ) : upgrade === "pro" || upgrade === "premier" ? ( + + ) : onManageTunnels ? ( + + ) : null; + return ( + + + You're out of tunnels (they cost us money). + + {upgrade === "link" + ? "Switch to Tailscale or get more tunnels by linking your T3 Chat sub (sorry!)" + : upgrade + ? "Switch to Tailscale or get more tunnels by upgrading your T3 Chat sub (sorry!)" + : "Switch to Tailscale or deregister an environment you no longer use (sorry!)"} + + {action ? {action} : null} + + ); +} diff --git a/apps/web/src/components/composerFooterLayout.test.ts b/apps/web/src/components/composerFooterLayout.test.ts index b923481c7cae8..73833568fd4f7 100644 --- a/apps/web/src/components/composerFooterLayout.test.ts +++ b/apps/web/src/components/composerFooterLayout.test.ts @@ -2,35 +2,10 @@ import { describe, expect, it } from "vite-plus/test"; import { resolveContextStripLabelsCompact } from "./BranchToolbar.logic"; import { - resolveComposerTimelineInset, resolveRestingComposerControlsLayout, resolveRestingComposerControlsNaturalWidth, } from "./composerFooterLayout"; -describe("resolveComposerTimelineInset", () => { - it("keeps timeline padding stable when the model-only strip appears on collapse", () => { - const expanded = resolveComposerTimelineInset({ - currentInset: 0, - overlayHeight: 172, - isResting: false, - }); - const collapsed = resolveComposerTimelineInset({ - currentInset: expanded, - overlayHeight: 110, - isResting: true, - restingOnlyHeight: 32, - }); - expect(collapsed).toBe(expanded); - expect( - resolveComposerTimelineInset({ - currentInset: collapsed, - overlayHeight: 172, - isResting: false, - }), - ).toBe(expanded); - }); -}); - describe("context strip labels and resting composer controls", () => { const stripWidth = 724; const labelWidth = 327; diff --git a/apps/web/src/components/composerFooterLayout.ts b/apps/web/src/components/composerFooterLayout.ts index 680010da9242a..963366b811d18 100644 --- a/apps/web/src/components/composerFooterLayout.ts +++ b/apps/web/src/components/composerFooterLayout.ts @@ -23,58 +23,6 @@ export function shouldUseCompactComposerFooter( return width !== null && width < breakpoint; } -export function shouldUseRestingComposerLayout(input: { - isExistingThread: boolean; - isMobileViewport: boolean; - isScrollCollapsed: boolean; - hasExpandedChrome: boolean; - hasMultilinePrompt: boolean; - timelineOverflows: boolean; -}): boolean { - return ( - input.isExistingThread && - !input.isMobileViewport && - input.timelineOverflows && - input.isScrollCollapsed && - !input.hasMultilinePrompt && - !input.hasExpandedChrome - ); -} - -/** - * How much taller the empty expanded composer is than its resting row on - * desktop widths, from the layout classes in ChatComposer: the body loses - * 8px of top padding, the prompt clamps from min-h-17.5 (70px) to 32px, and - * the 48px footer leaves flow. - */ -const COMPOSER_RESTING_EXPANSION_MIN_PX = 94; - -/** Reserve expansion height because the timeline does not move when footer growth covers its last rows. */ -export function resolveComposerTimelineInset(input: { - currentInset: number; - overlayHeight: number; - isResting: boolean; - restingOnlyHeight?: number; -}): number { - const overlayHeight = Math.ceil(input.overlayHeight); - return input.isResting - ? Math.max( - input.currentInset, - overlayHeight + - (input.currentInset === 0 - ? COMPOSER_RESTING_EXPANSION_MIN_PX - (input.restingOnlyHeight ?? 0) - : 0), - ) - : overlayHeight; -} - -export function overlayComposerIsResting(input: { - composerMounted: boolean; - composerReportedResting: boolean; -}): boolean { - return input.composerMounted && input.composerReportedResting; -} - export function shouldAnimateComposerRestingTransition(input: { hasCompletedInitialLayout: boolean; stateChanged: boolean; diff --git a/apps/web/src/components/device/DeviceControlsRail.tsx b/apps/web/src/components/device/DeviceControlsRail.tsx index d7ee2cd642356..3ed2b3a4fa544 100644 --- a/apps/web/src/components/device/DeviceControlsRail.tsx +++ b/apps/web/src/components/device/DeviceControlsRail.tsx @@ -46,6 +46,8 @@ export function DeviceControlsRail(props: { onFloat: () => void; onClose: () => void; onPowerOff: () => void; + /** The host takes input but runs no device commands (Simlock): no rotation, settings, tools, or screenshots. */ + inputOnly?: boolean; }) { const { view, handle, controls } = props; const popupSide = "left"; @@ -85,115 +87,123 @@ export function DeviceControlsRail(props: { > + {props.inputOnly ? null : ( + + + } + > + + + + void controls.act({ type: "setOrientation", value: "portrait" })} + > + Portrait + + + void controls.act({ type: "setOrientation", value: "landscape_left" }) + } + > + Landscape + + + + )} + + ) : props.inputOnly ? null : ( + handle?.rotate()} + > + + + )} + {props.inputOnly ? null : ( + <> + + void controls.act({ type: "setAppearance", value: nextAppearance })} + > + + } > - + - - void controls.act({ type: "setOrientation", value: "portrait" })} + + { + if ( + value === "small" || + value === "default" || + value === "large" || + value === "extra-large" + ) + void controls.act({ type: "setTextSize", value }); + }} > - Portrait - - - void controls.act({ type: "setOrientation", value: "landscape_left" }) - } - > - Landscape - + {( + [ + ["small", "Small"], + ["default", "Default"], + ["large", "Large"], + ["extra-large", "Extra large"], + ] as const + ).map(([value, label]) => ( + + + {label} + + + + ))} + + + + - ) : ( + )} + {props.inputOnly ? null : ( handle?.rotate()} + label={props.screenshotPending ? "Capturing screenshot" : "Save screenshot"} + disabled={!view.streaming || props.screenshotPending} + onClick={props.onScreenshot} > - + )} - - void controls.act({ type: "setAppearance", value: nextAppearance })} - > - - - - - } - > - - - - { - if ( - value === "small" || - value === "default" || - value === "large" || - value === "extra-large" - ) - void controls.act({ type: "setTextSize", value }); - }} - > - {( - [ - ["small", "Small"], - ["default", "Default"], - ["large", "Large"], - ["extra-large", "Extra large"], - ] as const - ).map(([value, label]) => ( - - - {label} - - - - ))} - - - - - - - - - - Power off device + {props.inputOnly ? "Return device to the pool" : "Power off device"} diff --git a/apps/web/src/components/device/DevicePanel.tsx b/apps/web/src/components/device/DevicePanel.tsx index 8eb40a82325f7..c8f55665113ac 100644 --- a/apps/web/src/components/device/DevicePanel.tsx +++ b/apps/web/src/components/device/DevicePanel.tsx @@ -86,6 +86,9 @@ export function DevicePanel(props: { (device) => device.hostId === activeSession.hostId && device.id === activeSession.deviceId, ) : undefined; + const activeHost = activeDevice + ? state.hosts.find((host) => host.id === activeDevice.hostId) + : undefined; const grouped = groupDevices(state); @@ -234,9 +237,8 @@ export function DevicePanel(props: { key={`${environmentId}\u0000${deviceKey(activeDevice)}`} environmentId={environmentId} device={activeDevice} - hostLabel={ - state.hosts.find((host) => host.id === activeDevice.hostId)?.label ?? "Device host" - } + hostLabel={activeHost?.label ?? "Device host"} + simlock={activeHost?.kind === "simlock"} hostDiagnostics={ diagnostics.find(({ hostId }) => hostId === activeDevice.hostId)?.detail } diff --git a/apps/web/src/components/device/DeviceSetup.tsx b/apps/web/src/components/device/DeviceSetup.tsx index d3866129795e8..9d7b30c0456a7 100644 --- a/apps/web/src/components/device/DeviceSetup.tsx +++ b/apps/web/src/components/device/DeviceSetup.tsx @@ -19,9 +19,9 @@ import { cn } from "~/lib/utils"; const platformName = (platform: DevicePlatform) => (platform === "ios" ? "iOS" : "Android"); -export const deviceHubDescription = +const deviceHubDescription = "Enable this environment to open simulators and emulators, whether they run here or on a remote device host."; -export const agentDeviceDescription = +const agentDeviceDescription = "Allow new agent sessions in this environment to start and control local and remote devices, with required tools set up automatically."; export const isAndroidDiscoveryUncertain = (state: DeviceServiceState) => @@ -57,7 +57,7 @@ function deviceHostDiagnostics( }); } -export function DeviceHostDiagnostics(props: { +function DeviceHostDiagnostics(props: { readonly hosts: ReadonlyArray>; readonly hostStatuses: DeviceServiceState["hostStatuses"]; readonly className?: string; @@ -360,7 +360,7 @@ export function AgentDeviceSetupStatus(props: { return null; } -export function PlatformStatus(props: { +function PlatformStatus(props: { readonly platform: string; readonly status: { readonly ready: boolean; readonly message: string }; readonly compact?: boolean; diff --git a/apps/web/src/components/device/DeviceStreamView.tsx b/apps/web/src/components/device/DeviceStreamView.tsx index 22ae466acb0ed..4b0b5c84ca5b9 100644 --- a/apps/web/src/components/device/DeviceStreamView.tsx +++ b/apps/web/src/components/device/DeviceStreamView.tsx @@ -93,6 +93,8 @@ export function DeviceStreamView(props: { readonly axOverlay?: boolean; /** Lets a parent read this view's own stream state, such as its screen size. */ readonly viewId?: string; + /** The host serves only serve-sim's stream and input socket (Simlock). */ + readonly serveSimOnly?: boolean; }) { const model = deviceModel(props.platform, props.deviceName ?? ""); const isDuo = model?.id === "iphone-duo"; @@ -118,6 +120,7 @@ export function DeviceStreamView(props: { platform: props.platform, deviceId: props.deviceId, access, + serveSimOnly: props.serveSimOnly === true, viewId, }) : NO_SESSION_ATOM, diff --git a/apps/web/src/components/device/DeviceWorkspace.tsx b/apps/web/src/components/device/DeviceWorkspace.tsx index b8d329bc0dfbf..b57adae023269 100644 --- a/apps/web/src/components/device/DeviceWorkspace.tsx +++ b/apps/web/src/components/device/DeviceWorkspace.tsx @@ -16,6 +16,8 @@ export function DeviceWorkspace(props: { environmentId: EnvironmentId; device: DeviceSummary; hostLabel: string; + /** Leased from a Simlock pool: serve-sim input, no host commands. */ + simlock?: boolean; hostDiagnostics: string | undefined; visible: boolean; onFloat: () => void; @@ -80,6 +82,7 @@ export function DeviceWorkspace(props: { deviceId={props.device.id} hostId={props.device.hostId} visible={props.visible} + serveSimOnly={props.simlock === true} axOverlay={axOverlay} allowPhoneView renderControls={(view) => ( @@ -95,6 +98,7 @@ export function DeviceWorkspace(props: { onFloat={props.onFloat} onClose={props.onClose} onPowerOff={props.onPowerOff} + inputOnly={props.simlock === true} /> )} /> diff --git a/apps/web/src/components/device/deviceStreamSession.ts b/apps/web/src/components/device/deviceStreamSession.ts index 813a4f3fd8e89..27fc79c97b03c 100644 --- a/apps/web/src/components/device/deviceStreamSession.ts +++ b/apps/web/src/components/device/deviceStreamSession.ts @@ -43,6 +43,8 @@ export interface DeviceStreamKey { readonly platform: DevicePlatform; readonly deviceId: string; readonly access: DeviceHubAccess; + /** See `DeviceStreamTarget.serveSimOnly`. */ + readonly serveSimOnly: boolean; /** Each mounted view owns its own client: one canvas cannot be in two places. */ readonly viewId: string; } @@ -89,7 +91,12 @@ export const deviceStreamSessionAtom = Atom.family((key: DeviceStreamKey) => get.setSelf(session); }; const client = createDeviceStreamClient( - { platform: key.platform, deviceId: key.deviceId, access: key.access }, + { + platform: key.platform, + deviceId: key.deviceId, + access: key.access, + serveSimOnly: key.serveSimOnly, + }, createCanvasFrameSink(canvas, () => frameListener?.()), { onDuoControl: (duoControl) => { diff --git a/apps/web/src/components/diffs/diffFileTree.logic.test.ts b/apps/web/src/components/diffs/diffFileTree.logic.test.ts index 757ab3f83de42..dea4b9981c8a6 100644 --- a/apps/web/src/components/diffs/diffFileTree.logic.test.ts +++ b/apps/web/src/components/diffs/diffFileTree.logic.test.ts @@ -1,7 +1,11 @@ import type { FileDiffMetadata } from "@pierre/diffs"; import { describe, expect, it } from "vite-plus/test"; -import { buildDiffFileTreeUpdates, diffFileTreeEntries } from "./diffFileTree.logic"; +import { + buildDiffFileTreeUpdates, + diffFileTreeEntries, + orderDiffFilesLikeTree, +} from "./diffFileTree.logic"; function file(type: FileDiffMetadata["type"], name: string, prevName = name): FileDiffMetadata { return { type, name, prevName } as FileDiffMetadata; @@ -24,6 +28,26 @@ describe("diffFileTreeEntries", () => { }); }); +describe("orderDiffFilesLikeTree", () => { + it("pulls a test listed last into its directory, keeping the first-seen order", () => { + expect( + orderDiffFilesLikeTree([ + file("change", "server/device/Host.ts"), + file("change", "web/settings.ts"), + file("change", "server/ws.ts"), + file("change", "server/device/Host.test.ts"), + file("change", "web/settings.test.ts"), + ]).map((entry) => entry.name), + ).toEqual([ + "server/device/Host.ts", + "server/device/Host.test.ts", + "server/ws.ts", + "web/settings.ts", + "web/settings.test.ts", + ]); + }); +}); + describe("buildDiffFileTreeUpdates", () => { it("adds a new file's directories before the file", () => { expect(buildDiffFileTreeUpdates(["README.md"], ["README.md", "src/lib/a.ts"])).toEqual([ diff --git a/apps/web/src/components/diffs/diffFileTree.logic.ts b/apps/web/src/components/diffs/diffFileTree.logic.ts index abb3a00c7689b..41d2a5e676e70 100644 --- a/apps/web/src/components/diffs/diffFileTree.logic.ts +++ b/apps/web/src/components/diffs/diffFileTree.logic.ts @@ -98,6 +98,36 @@ export function diffFileTreePositions(paths: ReadonlyArray): ReadonlyMap return positions; } +// A flat list in tree order: each directory stays contiguous, siblings ranked as +// compareDiffFileTreeEntries ranks them, so the list never disagrees with the tree. +export function orderDiffFilesLikeTree( + files: ReadonlyArray, +): ReadonlyArray { + const paths = files.map(resolveFileDiffPath); + const positions = diffFileTreePositions(paths); + const keys = paths.map((path) => { + const key: number[] = []; + let directory = ""; + for (const segment of path.split("/").slice(0, -1)) { + directory += `${segment}/`; + key.push(positions.get(directory)!); + } + key.push(positions.get(path)!); + return key; + }); + return files + .map((file, index) => ({ file, key: keys[index]! })) + .toSorted((left, right) => { + const length = Math.min(left.key.length, right.key.length); + for (let index = 0; index < length; index++) { + const difference = left.key[index]! - right.key[index]!; + if (difference !== 0) return difference; + } + return left.key.length - right.key.length; + }) + .map(({ file }) => file); +} + export function compareDiffFileTreeEntries( getPositions: () => ReadonlyMap, ): FileTreeSortComparator { diff --git a/apps/web/src/components/diffs/useHideTestFiles.ts b/apps/web/src/components/diffs/useHideTestFiles.ts new file mode 100644 index 0000000000000..a1f806ecb467d --- /dev/null +++ b/apps/web/src/components/diffs/useHideTestFiles.ts @@ -0,0 +1,9 @@ +import * as Schema from "effect/Schema"; + +import { useLocalStorage } from "~/hooks/useLocalStorage"; + +const HIDE_TEST_FILES_STORAGE_KEY = "t3code.diffHideTestFiles"; + +export function useHideTestFiles() { + return useLocalStorage(HIDE_TEST_FILES_STORAGE_KEY, false, Schema.Boolean); +} diff --git a/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx b/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx index 9879edfeedbd8..7492313f3c7e8 100644 --- a/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx +++ b/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx @@ -18,7 +18,9 @@ vi.mock(import("~/assets/assetUrls"), async (original) => ({ // The real source view highlights through a worker pool; the text it receives is what matters here. // Never load the original: its highlighter graph can outlast waitFor on a cold CI browser. vi.mock(import("./ReadOnlySourcePreview"), () => ({ - default: ({ text }: { text: string }) =>
    {text}
    , + default: ({ text, wordWrap }: { text: string; wordWrap?: boolean }) => ( +
    {text}
    + ), })); // The Word engine compiles about 30 MB of WebAssembly in a fresh worker per document, which took @@ -41,6 +43,7 @@ vi.mock(import("./office/WordDocument"), async () => { }; }); +import { getClientSettings } from "~/hooks/useSettings"; import { AttachmentFilePreview } from "./AttachmentFilePreview"; import LargeTextViewer from "./LargeTextViewer"; import VirtualTablePreview from "./VirtualTablePreview"; @@ -492,6 +495,7 @@ describe("large text preview recovery", () => { workspaceMutationId={mutation} revealLine={null} revealRequestId={0} + wordWrap />
    ); @@ -2341,6 +2345,22 @@ it("toggles a Mermaid attachment between diagram and source through the lazy pre expect(screen.container.querySelector('[role="alert"]')).toBeNull(); }); +it("unwraps one text attachment without turning off the word wrap setting", async () => { + const file = new Blob(["a long line"], { type: "text/plain" }); + await using screen = await render( + , + ); + await screen.getByRole("button", { name: "Disable word wrap" }).click(); + await expect.element(screen.getByRole("button", { name: "Enable word wrap" })).toBeVisible(); + expect(getClientSettings().wordWrap).toBe(true); + expect(screen.container.querySelector("pre")?.dataset.wordWrap).toBe("false"); +}); + it("mounts a bounded set of saved notebook outputs as the reader scrolls", async () => { const notebook = { nbformat: 4, diff --git a/apps/web/src/components/files/AttachmentFilePreview.tsx b/apps/web/src/components/files/AttachmentFilePreview.tsx index e57289bd60144..1d8bc609ff56a 100644 --- a/apps/web/src/components/files/AttachmentFilePreview.tsx +++ b/apps/web/src/components/files/AttachmentFilePreview.tsx @@ -21,7 +21,7 @@ import { MorphIcon } from "~/components/MorphIcon"; import { ScrollArea } from "~/components/ui/scroll-area"; import { toastManager } from "~/components/ui/toast"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; -import { useClientSettings, updateClientSettings } from "~/hooks/useSettings"; +import { useClientSettings } from "~/hooks/useSettings"; import { cn } from "~/lib/utils"; import { BinaryFilePreview } from "./BinaryFilePreview"; @@ -55,7 +55,7 @@ const SourcePreview = lazy(() => import("./ReadOnlySourcePreview")); const STALE_URL_MS = 5 * 60_000; -export function ReadOnlySourcePreview(props: { name: string; text: string }) { +export function ReadOnlySourcePreview(props: { name: string; text: string; wordWrap?: boolean }) { return ( }> @@ -214,7 +214,9 @@ export function AttachmentFilePreview(props: { return () => controller.abort(); }, [url, needsText, streamsText, revision, props.sizeBytes, props.file, refresh]); const failure = error ?? (needsText ? contentError : null); - const wordWrap = useClientSettings((settings) => settings.wordWrap); + const defaultWordWrap = useClientSettings((settings) => settings.wordWrap); + const [wordWrapOverride, setWordWrapOverride] = useState(null); + const wordWrap = wordWrapOverride ?? defaultWordWrap; const showsRawText = failure === null && needsText && @@ -272,6 +274,7 @@ export function AttachmentFilePreview(props: { onRetry={() => setRevision((value) => value + 1)} delimiter={rendered ? delimiter : null} copyContents={copyContents} + wordWrap={wordWrap} /> ) : !url || (needsText && !content) ? ( @@ -292,7 +295,7 @@ export function AttachmentFilePreview(props: { /> ) : ( - + ) ) : kind === "archive" || kind === "sqlite" ? ( updateClientSettings({ wordWrap: !wordWrap })} + onPress={() => setWordWrapOverride(!wordWrap)} > diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index 5a4ccdef5bdf7..30b27a519a73d 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -59,7 +59,7 @@ import { RefreshIcon } from "~/components/ui/refresh-icon"; import { isCommandPaletteOpen } from "~/commandPaletteBus"; import { resolveShortcutCommand, shortcutLabelForCommand } from "~/keybindings"; import { useRemoteOpenState } from "~/remoteOpen"; -import { useClientSettings, updateClientSettings } from "~/hooks/useSettings"; +import { useClientSettings } from "~/hooks/useSettings"; import { useTheme } from "~/hooks/useTheme"; import { getLocalStorageItem, setLocalStorageItem, useLocalStorage } from "~/hooks/useLocalStorage"; import { resolveDiffThemeName } from "~/lib/diffRendering"; @@ -999,7 +999,9 @@ export default function FilePreviewPanel({ attachment === undefined ? resolveFilePreviewPath(requestedPath, cwd) : requestedPath; const draft = typeof composerDraftTarget === "string"; const { resolvedTheme } = useTheme(); - const wordWrap = useClientSettings((settings) => settings.wordWrap); + const defaultWordWrap = useClientSettings((settings) => settings.wordWrap); + const [wordWrapOverride, setWordWrapOverride] = useState(null); + const wordWrap = wordWrapOverride ?? defaultWordWrap; const canOperatePreview = useEnvironmentScope(environmentId, AuthPreviewOperateScope); const primaryEnvironmentId = usePrimaryEnvironmentId(); const remoteOpenState = useRemoteOpenState(environmentId); @@ -1347,7 +1349,7 @@ export default function FilePreviewPanel({ updateClientSettings({ wordWrap: !wordWrap })} + onPress={() => setWordWrapOverride(!wordWrap)} > @@ -1459,6 +1461,7 @@ export default function FilePreviewPanel({ workspaceMutationId={workspaceMutationId} revealLine={revealLine} revealRequestId={revealRequestId} + wordWrap={wordWrap} /> ) : relativePath && absolutePath && @@ -1544,6 +1547,7 @@ export default function FilePreviewPanel({ text={file.data.contents} cacheKey={projectFileCacheKey(cwd, relativePath, file.data.contents)} onPostRender={onFilePostRender} + wordWrap={wordWrap} /> ) : ( @@ -1613,6 +1617,7 @@ export function WorkspaceLargeTextPreview(props: { workspaceMutationId: string | null; revealLine: number | null; revealRequestId: number; + wordWrap: boolean; }) { const resource = { _tag: "media-file", @@ -1715,6 +1720,7 @@ export function WorkspaceLargeTextPreview(props: { delimiter={props.delimiter} revealLine={props.revealLine} revealRequestId={props.revealRequestId} + wordWrap={props.wordWrap} /> diff --git a/apps/web/src/components/files/LargeTextViewer.tsx b/apps/web/src/components/files/LargeTextViewer.tsx index a12842f7047c9..097f0dc717f70 100644 --- a/apps/web/src/components/files/LargeTextViewer.tsx +++ b/apps/web/src/components/files/LargeTextViewer.tsx @@ -36,6 +36,8 @@ export default function LargeTextViewer(props: { readonly delimiter: "," | "\t" | null; readonly revealLine?: number | null; readonly revealRequestId?: number; + /** A preview's own wrap toggle; omitted follows the word wrap setting. */ + readonly wordWrap?: boolean; }) { const [meta, setMeta] = useState | null>(null); const [pages, setPages] = useState(new Map()); @@ -46,7 +48,8 @@ export default function LargeTextViewer(props: { reject: (cause: Error) => void; } | null>(null); const pending = useRef(new Set()); - const wordWrap = useClientSettings((settings) => settings.wordWrap); + const defaultWordWrap = useClientSettings((settings) => settings.wordWrap); + const wordWrap = props.wordWrap ?? defaultWordWrap; // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Own the prefix fetch and indexing worker, cancelling requests and pending copies when the source changes or unmounts. useEffect(() => { // oxlint-disable-next-line react/set-state-in-effect -- A replacement source must discard the previous worker's error and pages. diff --git a/apps/web/src/components/files/ReadOnlySourcePreview.tsx b/apps/web/src/components/files/ReadOnlySourcePreview.tsx index b35143fd36575..2fdfe04a238d6 100644 --- a/apps/web/src/components/files/ReadOnlySourcePreview.tsx +++ b/apps/web/src/components/files/ReadOnlySourcePreview.tsx @@ -13,9 +13,12 @@ export default function ReadOnlySourcePreview(props: { readonly text: string; readonly cacheKey?: string; readonly onPostRender?: FileOptions["onPostRender"]; + /** A preview's own wrap toggle; omitted follows the word wrap setting. */ + readonly wordWrap?: boolean; }) { const resolvedTheme = useRenderedAppearance(); - const wordWrap = useClientSettings((settings) => settings.wordWrap); + const defaultWordWrap = useClientSettings((settings) => settings.wordWrap); + const wordWrap = props.wordWrap ?? defaultWordWrap; return ( (); const NO_TURN_DIFFS: ReadonlyArray = []; const NO_OPTIMISTIC_MESSAGES: ReadonlyArray = []; const ignore = () => {}; @@ -401,7 +398,6 @@ function GuestThread({ const follow = useTimelineFollow({ unfocusedSplitPaneRef, legendListRef: listRef, - composerRef: NO_COMPOSER, composerTimelineInset: 0, routeThreadKey: threadKey, activeThreadKey: threadKey, diff --git a/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx b/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx index 26ed49fba0fa5..50b5ae47c449b 100644 --- a/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx +++ b/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx @@ -366,8 +366,8 @@ function DeviceMiniPlayer({ const device = deviceState.devices.find( (entry) => entry.hostId === source.hostId && entry.id === source.deviceId, ); - const hostLabel = - deviceState.hosts.find((host) => host.id === source.hostId)?.label ?? "Device host"; + const host = deviceState.hosts.find((entry) => entry.id === source.hostId); + const hostLabel = host?.label ?? "Device host"; const cornerRadius = (player: PreviewMiniPlayerSize) => resolveDeviceMiniPlayerCornerRadius(source.platform, player); @@ -404,6 +404,7 @@ function DeviceMiniPlayer({ deviceDescription={`${hostLabel} · ${device?.version ?? source.platform}`} visible viewId={viewId} + serveSimOnly={host?.kind === "simlock"} />
    )} 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..328274649e62a --- /dev/null +++ b/apps/web/src/components/pullRequest/PullRequestActionsMenu.browser.test.tsx @@ -0,0 +1,214 @@ +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("~/browser/useOpenLink"), () => ({ useLinkClickHandler: () => () => {} }) 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 6a594030c2780..1745e3d0c27d2 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 { useLinkClickHandler } from "~/browser/useOpenLink"; +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"; @@ -84,6 +96,8 @@ export function PullRequestActionsMenu({ onSelectMergeMethod, showsDeleteBranch, environmentConnected, + canWriteSourceControl, + onMaintainerEditsSaved, confirmDeleteBranch, pendingAction, perform, @@ -93,7 +107,17 @@ export function PullRequestActionsMenu({ environmentId: EnvironmentId; reference: PullRequestRef; pullRequestKey: string; - detail: Pick; + detail: Pick< + PullRequestDetail, + | "url" + | "state" + | "isDraft" + | "headBranch" + | "capabilities" + | "viewerPermissions" + | "maintainerCanModify" + | "observedAt" + >; threadRef: ScopedThreadRef | null; terminology: ChangeRequestTerminology; linkThreadRef: ScopedThreadRef | null; @@ -125,6 +149,8 @@ export function PullRequestActionsMenu({ onSelectMergeMethod: (method: PullRequestMergeMethod) => void; showsDeleteBranch: boolean; environmentConnected: boolean; + canWriteSourceControl: boolean; + onMaintainerEditsSaved: () => void; confirmDeleteBranch: () => Promise; pendingAction: PullRequestAction | null; perform: PerformPullRequestAction; @@ -135,6 +161,47 @@ export function PullRequestActionsMenu({ const inlineConfirm = useInlineActionConfirm(pullRequestKey); const actionPending = pendingAction !== null; const onLinkClick = useLinkClickHandler(threadRef); + 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 ( @@ -221,6 +288,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 8599a684b8781..ecc875c07fd7f 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"; @@ -314,6 +317,7 @@ function PullRequestCodeTab({ false, Schema.Boolean, ); + const [hideTestFiles, setHideTestFiles] = useHideTestFiles(); const [selectedLines, setSelectedLines] = useState<{ id: string; range: SelectedLineRange; @@ -503,9 +507,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) => @@ -1089,6 +1106,11 @@ function PullRequestCodeTab({ {files.length} {files.length === 1 ? "file" : "files"} {nextCursor === null ? "" : "+"} + {hiddenTestFileCount > 0 ? ( + + ) : null} {filesViewed.enabled && files.length > 0 ? ( @@ -1301,6 +1323,26 @@ function PullRequestCodeTab({ ) : null} + {testFileCount > 0 ? ( + + setHideTestFiles(Boolean(pressed))} + /> + } + > + + + + {hideTestFiles ? "Show test files" : "Hide test files"} + + + ) : null} {fileKeys.length > 0 ? ( -
    +
    {detail ? ( {!nativeStack && supportsStackActions && nativeStackQuery.error ? ( @@ -1474,6 +1474,8 @@ export function PullRequestDetailPanel({ onSelectMergeMethod={selectMergeMethod} showsDeleteBranch={showsDeleteBranch} environmentConnected={environmentConnected} + canWriteSourceControl={canWriteSourceControl} + onMaintainerEditsSaved={refreshDetail} confirmDeleteBranch={confirmDeleteBranch} pendingAction={pendingAction} perform={perform} diff --git a/apps/web/src/components/quickMessage/QuickMessagePanel.tsx b/apps/web/src/components/quickMessage/QuickMessagePanel.tsx index 0ed2e4e2797df..b398a1de8f50c 100644 --- a/apps/web/src/components/quickMessage/QuickMessagePanel.tsx +++ b/apps/web/src/components/quickMessage/QuickMessagePanel.tsx @@ -111,12 +111,7 @@ import { PullRequestAttachPickerHost } from "../chat/PullRequestAttachPicker"; import { RepositoryAttachPickerHost } from "../chat/RepositoryAttachPicker"; import { SlackMessagePickerHost } from "../chat/SlackMessagePicker"; import { ThreadAttachPickerHost } from "../chat/ThreadAttachPicker"; -import { - ComposerControl, - ComposerControlChevron, - ComposerControlIcon, - ComposerControlSeparator, -} from "../chat/ComposerControl"; +import { ComposerControl, ComposerControlIcon } from "../chat/ComposerControl"; import { ComposerPrimaryActions } from "../chat/ComposerPrimaryActions"; import { ComposerImageThumbnail } from "../chat/ComposerImageThumbnail"; import { ComposerSurface } from "../chat/ComposerSurface"; @@ -664,7 +659,6 @@ export function QuickMessagePanel() { {selectedProjectItem?.label ?? "Choose a project"} - - - = { full: "Full", }; +const EFFORT_DISPLAY_LABELS: Record = { + label: "Word", + bars: "Bars", +}; + export function AppearanceSettingsPanel() { const { appearanceMode, @@ -270,6 +276,39 @@ export function AppearanceSettingsPanel() {
    } /> + + updateSettings({ effortDisplay: DEFAULT_UNIFIED_SETTINGS.effortDisplay }) + } + /> + ) : null + } + control={ +
    + +
    + } + /> (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 d0d1d2c1cec89..7db523121ee4e 100644 --- a/apps/web/src/components/settings/IntegrationsSettings.tsx +++ b/apps/web/src/components/settings/IntegrationsSettings.tsx @@ -3,8 +3,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, @@ -43,7 +42,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"; @@ -59,11 +57,6 @@ import { AgentDeviceSetupStatus, DeviceHubSetupStatus, deviceHubReadiness, - PlatformStatus, - platformSetupStatus, - deviceHubDescription, - agentDeviceDescription, - DeviceHostDiagnostics, } from "~/components/device/DeviceSetup"; import { isElectron } from "../../env"; @@ -804,15 +797,23 @@ function DeviceIntegrationSettings() { const connected = selected?.connection.phase === "connected" && selected.serverConfig !== null; const environmentId = connected ? selected.environmentId : null; + const { state } = useDeviceState(environmentId); + return ( - - + + + + - + ); } @@ -838,20 +839,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, @@ -948,14 +936,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 5f944c867db68..1f51e20c0f839 100644 --- a/apps/web/src/components/settings/LabsSettingsPanel.tsx +++ b/apps/web/src/components/settings/LabsSettingsPanel.tsx @@ -17,9 +17,12 @@ export function LabsSettingsPanel() { + + {/* Clerk is only mounted when this build has T3 Connect. */} {hasCloudPublicConfig() ? : } + @@ -47,6 +50,46 @@ function BoringSidebarRow() { ); } +function BoringSidebarMultilineRow() { + const settings = useScopedSettings(); + const updateSettings = useUpdateScopedSettings(); + return ( + + updateSettings({ boringSidebarMultilineRows: Boolean(checked) }) + } + aria-label="Two-line folder rows" + /> + } + /> + ); +} + +function ThreadDetailsUsageRow() { + const settings = useScopedSettings(); + const updateSettings = useUpdateScopedSettings(); + return ( + + updateSettings({ threadDetailsUsageEnabled: Boolean(checked) }) + } + aria-label="Usage in thread details" + /> + } + /> + ); +} + /** Signed out of T3 Connect, the row offers to sign in instead of its switch. */ function GuestLinksRowWithSignIn() { const { isSignedIn } = useAuth(); @@ -127,3 +170,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 65a5133c22bbe..7119f06ca4d4e 100644 --- a/apps/web/src/components/settings/ProjectDefaultsSettings.tsx +++ b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx @@ -88,6 +88,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"]); @@ -282,6 +290,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} {threadGroupRow} ) : category === "general" ? ( <> {modelRow} + {fallbackRow} (null); + if (tunnelLimit === null) { + if (usedAtRefusal !== null) setUsedAtRefusal(null); + } else if (usedAtRefusal === null && usage.used !== null) { + setUsedAtRefusal(usage.used); + } + const showLimitAlert = + tunnelLimit !== null && + !(usedAtRefusal !== null && usage.used !== null && usage.used < usedAtRefusal) && + !(usage.limit !== null && usage.limit > tunnelLimit); + const meter = + usage.used !== null && usage.limit !== null ? ( + openAccountPage("t3-chat")}>Get more tunnels + ) : null + } + /> + ) : null; + return ( + <> + {meter || showLimitAlert ? ( +
    + {meter} + {showLimitAlert ? ( + openAccountPage("t3-chat") : null} + onManageTunnels={openAccountPage ? () => openAccountPage("t3-connect") : null} + /> + ) : null} +
    + ) : null} + {/* Kept outside the conditional so an open account page does not remount. */} + {accountPage.portals} + + ); +} + function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: boolean }) { const primaryEnvironmentId = usePrimaryEnvironmentId(); const { @@ -474,6 +527,7 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b publishAgentActivity, holdWebhooksWhileOffline, operationError, + tunnelLimit, reconcileCloudState, } = useCloudLinkController(); const { openAuthPrompt } = useT3ConnectAuthPrompt(); @@ -625,7 +679,9 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b /> ) } - /> + > + {signedOut ? null : } +
    ) : linkRevoked ? ( void) { ? ["Diff colors"] : []), ...(settings.chatWidth !== DEFAULT_UNIFIED_SETTINGS.chatWidth ? ["Chat width"] : []), + ...(settings.effortDisplay !== DEFAULT_UNIFIED_SETTINGS.effortDisplay + ? ["Effort display"] + : []), ...(settings.mathRenderingEnabled !== DEFAULT_UNIFIED_SETTINGS.mathRenderingEnabled ? ["Render math"] : []), @@ -530,9 +533,6 @@ export function useSettingsRestore(onRestored?: () => void) { ...(settings.showSkillsInSlashMenu !== DEFAULT_UNIFIED_SETTINGS.showSkillsInSlashMenu ? ["Show skills in slash menu"] : []), - ...(settings.composerCollapseOnScroll !== DEFAULT_UNIFIED_SETTINGS.composerCollapseOnScroll - ? ["Collapse composer on scroll"] - : []), ...(settings.composerRichTextEnabled !== DEFAULT_UNIFIED_SETTINGS.composerRichTextEnabled ? ["Rich text composer"] : []), @@ -654,6 +654,7 @@ export function useSettingsRestore(onRestored?: () => void) { appearanceContrast: DEFAULT_UNIFIED_SETTINGS.appearanceContrast, diffColorScheme: DEFAULT_UNIFIED_SETTINGS.diffColorScheme, chatWidth: DEFAULT_UNIFIED_SETTINGS.chatWidth, + effortDisplay: DEFAULT_UNIFIED_SETTINGS.effortDisplay, mathRenderingEnabled: DEFAULT_UNIFIED_SETTINGS.mathRenderingEnabled, timestampFormat: DEFAULT_UNIFIED_SETTINGS.timestampFormat, notificationMode: DEFAULT_UNIFIED_SETTINGS.notificationMode, @@ -667,7 +668,6 @@ export function useSettingsRestore(onRestored?: () => void) { pullRequestOpenTarget: DEFAULT_UNIFIED_SETTINGS.pullRequestOpenTarget, proactivePanelsEnabled: DEFAULT_UNIFIED_SETTINGS.proactivePanelsEnabled, showSkillsInSlashMenu: DEFAULT_UNIFIED_SETTINGS.showSkillsInSlashMenu, - composerCollapseOnScroll: DEFAULT_UNIFIED_SETTINGS.composerCollapseOnScroll, composerRichTextEnabled: DEFAULT_UNIFIED_SETTINGS.composerRichTextEnabled, sendShortcut: DEFAULT_UNIFIED_SETTINGS.sendShortcut, followUpBehavior: DEFAULT_UNIFIED_SETTINGS.followUpBehavior, @@ -1439,32 +1439,6 @@ export function ChatSettingsPanel() { } /> - - updateSettings({ - composerCollapseOnScroll: DEFAULT_UNIFIED_SETTINGS.composerCollapseOnScroll, - }) - } - /> - ) : null - } - control={ - - updateSettings({ composerCollapseOnScroll: Boolean(checked) }) - } - aria-label="Collapse composer on scroll" - /> - } - /> ) => { + const error = Cause.squash(cause); + return error instanceof Error ? error.message : String(error); +}; + +/** The gateway each environment's own agent-device points at, if any. */ +const machineGatewaysAtom = environmentAtomList("web-simlock-machine-gateways", (environmentId) => + deviceSimlockIdentityCheck({ environmentId, input: {} }), +); + +const NO_CHECK = Atom.make(AsyncResult.initial()).pipe(Atom.withLabel("simlock-identity:none")); + +/** Shared fields once, then one agent token per environment: Simlock counts leases per token. */ +export function SimlockDeviceHostEditor(props: { + host: SimlockDeviceHostConfig; + isNew: boolean; + environments: ReadonlyArray; + busy: boolean; + onSave: ( + host: SimlockDeviceHostConfig, + tokens: ReadonlyMap, + ) => void; + onClose: () => void; +}) { + const [draft, setDraft] = useState(props.host); + const [urlEdited, setUrlEdited] = useState(false); + const machineGateways = useAtomValue( + machineGatewaysAtom( + environmentIdsKey( + props.isNew ? props.environments.map((environment) => environment.environmentId) : [], + ), + ), + ); + // A new host starts at the gateway an environment's agent-device already uses. + const detectedUrl = machineGateways + .map((result) => Option.getOrUndefined(AsyncResult.value(result))?.gatewayUrl) + .find(Boolean); + const gatewayUrl = urlEdited || draft.gatewayUrl ? draft.gatewayUrl : (detectedUrl ?? ""); + const savedToken = (environment: SimlockHostEnvironment) => + environment.hosts.some((host) => host.id === props.host.id && host.agentToken !== ""); + const [tokens, setTokens] = useState>( + () => + new Map( + props.environments.map((environment) => [ + environment.environmentId, + savedToken(environment) ? { kind: "saved" } : { kind: "cleared" }, + ]), + ), + ); + const next: SimlockDeviceHostConfig = { + ...draft, + label: draft.label.trim(), + gatewayUrl: gatewayUrl.trim(), + }; + const checkUrl = simlockDeviceHostProblem(next) === null ? next.gatewayUrl : null; + const plan = planSimlockDeviceHostSave(props.environments, next, tokens); + const problem = "problem" in plan ? plan.problem : null; + const several = props.environments.length > 1; + + const limit = ( + field: "maxLeases" | "maxLeaseMinutes", + label: string, + min: number, + max: number, + ) => ( + + ); + + return ( + { + if (!open && !props.busy) props.onClose(); + }} + > + +
    { + event.preventDefault(); + if (problem === null && !props.busy) props.onSave(next, tokens); + }} + > + + {props.isNew ? "Add Simlock host" : "Edit Simlock host"} + + {several + ? "Each selected environment's server leases devices from your Simlock gateway with its own agent token, and releases them when they close." + : "The environment's server leases devices from your Simlock gateway and releases them when they close."} + + + + + +
    + {props.environments.map((environment) => ( + + setTokens((current) => new Map(current).set(environment.environmentId, token)) + } + /> + ))} +

    + Leases, drives, and streams devices. Create one{several ? " per environment" : ""}{" "} + with simlock --gateway token create --role agent, 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/SnoozePresetsSettings.logic.test.ts b/apps/web/src/components/settings/SnoozePresetsSettings.logic.test.ts index e5126ba8bba6d..05d62987fb23e 100644 --- a/apps/web/src/components/settings/SnoozePresetsSettings.logic.test.ts +++ b/apps/web/src/components/settings/SnoozePresetsSettings.logic.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; -import { reconcileSnoozeDraft } from "./SnoozePresetsSettings.logic"; +import { reconcileSnoozeDraft, sortSnoozeRows } from "./SnoozePresetsSettings.logic"; describe("reconcileSnoozeDraft", () => { it("keeps the draft while our own writes echo back in order", () => { @@ -18,3 +18,21 @@ describe("reconcileSnoozeDraft", () => { expect(reconcileSnoozeDraft(["a", "b", "a"], "b")).toEqual({ adopt: false, pending: ["a"] }); }); }); + +describe("sortSnoozeRows", () => { + it("orders rows by wake time and puts a passed today option last", () => { + // 16:00 local, so 20 hours wakes tomorrow and today 09:00 has passed. + const now = new Date(2026, 9, 28, 16, 0); + const sorted = sortSnoozeRows( + [ + { kind: "after", minutes: 20 * 60 }, + { kind: "at", day: "today", time: "09:00" }, + { kind: "at", day: "today", time: "18:00" }, + { kind: "after", minutes: 60 }, + ], + ["20h", "passed", "evening", "1h"], + now, + ); + expect(sorted.keys).toEqual(["1h", "evening", "20h", "passed"]); + }); +}); diff --git a/apps/web/src/components/settings/SnoozePresetsSettings.logic.ts b/apps/web/src/components/settings/SnoozePresetsSettings.logic.ts index ece0e2ee5a922..5e778be591b68 100644 --- a/apps/web/src/components/settings/SnoozePresetsSettings.logic.ts +++ b/apps/web/src/components/settings/SnoozePresetsSettings.logic.ts @@ -1,3 +1,5 @@ +import type { SnoozePresetSetting } from "@t3tools/contracts/settings"; +import { resolveSnoozePreset } from "@t3tools/client-runtime/state/thread-settled"; import * as Equal from "effect/Equal"; /** @@ -13,3 +15,26 @@ export function reconcileSnoozeDraft( ? { adopt: true } : { adopt: false, pending: pending.slice(acknowledged + 1) }; } + +/** + * Orders editor rows the way the menu lists them: soonest wake first, with options that + * have no wake right now (a passed "Today") last. Ties keep their current order. + */ +export function sortSnoozeRows( + presets: ReadonlyArray, + keys: ReadonlyArray, + now: Date, +): { readonly presets: ReadonlyArray; readonly keys: ReadonlyArray } { + const order = presets + .map((preset, index) => ({ + index, + wake: resolveSnoozePreset(preset, now)?.snoozedUntil ?? null, + })) + .toSorted((a, b) => + a.wake === b.wake ? 0 : a.wake === null ? 1 : b.wake === null ? -1 : a.wake < b.wake ? -1 : 1, + ); + return { + presets: order.map(({ index }) => presets[index]!), + keys: order.map(({ index }) => keys[index]!), + }; +} diff --git a/apps/web/src/components/settings/SnoozePresetsSettings.tsx b/apps/web/src/components/settings/SnoozePresetsSettings.tsx index d7129e76dd208..22f2d43497fde 100644 --- a/apps/web/src/components/settings/SnoozePresetsSettings.tsx +++ b/apps/web/src/components/settings/SnoozePresetsSettings.tsx @@ -16,7 +16,7 @@ import * as Schema from "effect/Schema"; import { PlusIcon, XIcon } from "lucide-react"; import { useNowMinute } from "../../hooks/useNowMinute"; -import { reconcileSnoozeDraft } from "./SnoozePresetsSettings.logic"; +import { reconcileSnoozeDraft, sortSnoozeRows } from "./SnoozePresetsSettings.logic"; import { formatShortTimestamp } from "../../timestampFormat"; import { Button } from "../ui/button"; import { Input } from "../ui/input"; @@ -90,6 +90,16 @@ function nextCandidate(presets: ReadonlyArray): SnoozePrese ); } +// Row order is only display order, so it does not make a list differ from the defaults. +function sameSnoozePresets( + left: ReadonlyArray, + right: ReadonlyArray, +) { + const ids = (presets: ReadonlyArray) => + presets.map(snoozePresetId).toSorted(); + return Equal.equals(ids(left), ids(right)); +} + const isSnoozePresetDay = Schema.is(SnoozePresetDay); interface KindMemory { @@ -109,16 +119,28 @@ function withWhen( return { kind: "at", day: when, time: preset.kind === "at" ? preset.time : memory.time }; } -/** Edits the snooze menu's options. Menus sort them by wake time, so rows keep the order they were added in. */ +/** + * Edits the snooze menu's options. Rows sort by wake time like the menu, but only when focus + * leaves the list, so a row never moves under the pointer mid-edit. + */ export function SnoozePresetsRow() { const settings = useScopedSettings(); const updateSettings = useUpdateScopedSettings(); const saved = settings.snoozePresets; + const nowMinute = useNowMinute(); + const now = new Date(`${nowMinute}Z`); // Edits build on a local draft, so a second edit before the first save echoes back // does not restore what the first one changed. Echoes of our own writes keep the draft. - const [presets, setPresets] = useState(saved); - // Row keys survive removals, so a row's in-progress field state stays with that row. - const [keys, setKeys] = useState>(() => saved.map((_, index) => index)); + // Row keys survive removals and sorts, so a row's in-progress field state stays with it. + // One state, so a sort queued in the same event as an edit applies on top of it. + const [rows, setRows] = useState(() => + sortSnoozeRows( + saved, + saved.map((_, index) => index), + now, + ), + ); + const { presets, keys } = rows; const [nextKey, setNextKey] = useState(saved.length); // Null forces the next render to adopt the saved value, as after a failed write. const [savedSource, setSavedSource] = useState | null>(saved); @@ -134,8 +156,7 @@ export function SnoozePresetsRow() { const reconciled = reconcileSnoozeDraft(written, saved); if (reconciled.adopt) { setWritten([]); - setPresets(saved); - setKeys(freshKeys(saved.length)); + setRows(sortSnoozeRows(saved, freshKeys(saved.length), now)); } else { setWritten(reconciled.pending); } @@ -145,8 +166,7 @@ export function SnoozePresetsRow() { nextKeys: ReadonlyArray = keys, ) => { if (!mixed && Equal.equals(snoozePresets, presets)) return; - setPresets(snoozePresets); - setKeys(nextKeys); + setRows({ presets: snoozePresets, keys: nextKeys }); setWritten((previous) => [...previous, snoozePresets]); updateSettings( { snoozePresets }, @@ -168,10 +188,17 @@ export function SnoozePresetsRow() { {...searchableSetting("snooze-options")} description="Choices in every snooze menu, listed soonest first. Weekdays mean the next one after today." resetAction={ - !mixed && Equal.equals(presets, DEFAULT_SNOOZE_PRESETS) ? null : ( + !mixed && sameSnoozePresets(presets, DEFAULT_SNOOZE_PRESETS) ? null : ( save(DEFAULT_SNOOZE_PRESETS, freshKeys(DEFAULT_SNOOZE_PRESETS.length))} + onClick={() => { + const sorted = sortSnoozeRows( + DEFAULT_SNOOZE_PRESETS, + freshKeys(DEFAULT_SNOOZE_PRESETS.length), + now, + ); + save(sorted.presets, sorted.keys); + }} /> ) } @@ -188,6 +215,7 @@ export function SnoozePresetsRow() { ) } onAdd={() => save([...presets, nextCandidate(presets)], [...keys, ...freshKeys(1)])} + onSettle={() => setRows((current) => sortSnoozeRows(current.presets, current.keys, now))} />
    ); @@ -200,6 +228,7 @@ function SnoozePresetList(props: { onChange: (index: number, preset: SnoozePresetSetting) => void; onRemove: (index: number) => void; onAdd: () => void; + onSettle: () => void; }) { const nowMinute = useNowMinute(); const now = new Date(`${nowMinute}Z`); @@ -232,7 +261,20 @@ function SnoozePresetList(props: { ); return ( -
    +
    { + const list = event.currentTarget; + const next = event.relatedTarget; + if (list.contains(next)) return; + // Select popups portal out of the list, so focus moving into one is not leaving it. + if (next instanceof Element && next.closest('[data-slot="select-popup"]')) return; + // Waits for the amount field's blur commit, and skips its Enter blur-and-refocus. + queueMicrotask(() => { + if (!list.contains(document.activeElement)) props.onSettle(); + }); + }} + > {props.presets.map((preset, index) => { const preview = previews[index]!; return ( @@ -371,7 +413,7 @@ function DurationFields(props: { minutes: number; onChange: (minutes: number) => <> updateSettings({ wordWrap: Boolean(checked) })} - aria-label="Wrap code, tables, diffs, and file previews by default" + aria-label="Wrap code, diffs, and file previews by default" /> } /> 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 2897ed0e38524..9c8904daef2b8 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -345,6 +345,12 @@ export const SETTINGS_SEARCH_ITEMS = [ to: "/settings/appearance", searchTerms: ["wide full width column layout messages composer monitor"], }, + { + id: "effort-display", + title: "Effort display", + to: "/settings/appearance", + searchTerms: ["reasoning effort thinking bars graph word label level composer"], + }, { id: "math-rendering", title: "Render math", @@ -416,7 +422,7 @@ export const SETTINGS_SEARCH_ITEMS = [ id: "word-wrap", title: "Word wrap", to: "/settings/appearance", - searchTerms: ["long lines code blocks tables diffs file previews"], + searchTerms: ["long lines code blocks diffs file previews"], }, { id: "composer-context", @@ -495,6 +501,18 @@ export const SETTINGS_SEARCH_ITEMS = [ to: "/settings/labs", searchTerms: ["beta folders project tree legacy old classic sidebar"], }, + { + id: "boring-sidebar-multiline", + title: "Two-line folder rows", + to: "/settings/labs", + searchTerms: ["beta boring sidebar folder rows multiline branch pull request details"], + }, + { + id: "thread-details-usage", + title: "Usage in thread details", + to: "/settings/labs", + searchTerms: ["beta limits 5 hour weekly context window tokens quota rate limit"], + }, { id: "guest-links", title: "Guest links", @@ -509,6 +527,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", @@ -627,12 +652,6 @@ export const SETTINGS_SEARCH_ITEMS = [ to: "/settings/chat", searchTerms: ["composer rich text tiptap bold italic markdown styled wysiwyg"], }, - { - id: "composer-collapse", - title: "Collapse composer on scroll", - to: "/settings/chat", - searchTerms: ["composer rest resting scroll wheel conversation timeline shrink minimize"], - }, { id: "send-shortcut", title: "Send shortcut", @@ -1003,31 +1022,33 @@ export const SETTINGS_SEARCH_ITEMS = [ scope: "environment-defaults", }, { - 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", @@ -1278,7 +1299,7 @@ export const SETTINGS_SEARCH_ITEMS = [ title: "T3 Connect", to: "/settings/access", targetId: "connections-environment", - searchTerms: ["managed tunnel cloud other devices remote"], + searchTerms: ["managed tunnel cloud other devices remote tunnels limit t3 chat"], desktopOnly: true, cloudOnly: true, }, 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/sidebar/SidebarChrome.tsx b/apps/web/src/components/sidebar/SidebarChrome.tsx index 4aea9584ec1d6..493254645fe0c 100644 --- a/apps/web/src/components/sidebar/SidebarChrome.tsx +++ b/apps/web/src/components/sidebar/SidebarChrome.tsx @@ -311,19 +311,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 ec7e0487a2880..930ab8895a263 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,7 +9,6 @@ import { type SidebarListMarker, } from "../Sidebar.logic"; import { SIDEBAR_SORTABLE_DRAG_DISABLED } from "./SidebarThreadRow"; -import { SidebarThreadUndoNotice } from "./SidebarThreadUndoNotice"; export function SortableSidebarMarker(props: { marker: SidebarListMarker; @@ -17,13 +16,20 @@ export function SortableSidebarMarker(props: { id?: string; className?: string; children?: ReactNode; + revealOverflow?: boolean; "data-testid"?: string; }) { - const { setNodeRef, transform, transition } = useSortable({ + const { node, setNodeRef, transform, transition } = useSortable({ id: props.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} ) : null; + const diff = latestRunDiff(thread); + // Only the provider icon would remain, so the row drops the line instead of leaving it blank. + const metaLineEmpty = + !thread.branch && + !showsScratchMachine && + prBadge === null && + diff === null && + !isRemote && + runningTerminalIds.length === 0; + // The two-line folder row's second line; the card keeps its own fuller version below. + const metaLine = metaLineEmpty ? null : ( +
    + {thread.branch ? ( + <> + + + + + + ) : showsScratchMachine ? ( + <> + + {props.scratchMachineLabel} + + ) : ( + + )} + {terminalStatusIcon} + {prBadge} + {diff ? ( + + +{formatDiffCount(diff.insertions)}{" "} + −{formatDiffCount(diff.deletions)} + + ) : null} + + {isRemote && !showsScratchMachine ? ( + + + + ) : null} + + +
    + ); + const titleLineProviderStack = metaLineEmpty ? ( + + + + ) : null; if (variant !== "card") { + const multiline = variant === "folder-multiline"; + const inFolder = variant === "folder" || multiline; return (
  • @@ -1018,161 +1092,186 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP data-sidebar-parent={props.navigationParent} aria-label={accessibility.label} aria-current={accessibility.current} - data-testid="sidebar-row-slim" + data-testid={inFolder ? "sidebar-row-folder" : "sidebar-row-slim"} aria-busy={isRegeneratingTitle || undefined} {...(fileDropHandlers ?? {})} - className={cn(rowSurfaceClassName, "flex h-9 items-center gap-2.5 px-2.5")} + className={cn( + rowSurfaceClassName, + multiline + ? "flex flex-col justify-center px-2.5 py-1.5" + : "flex h-9 items-center gap-2.5 px-2.5", + // Lines titles up with the folder name, past its chevron and icon. + inFolder && "ps-9", + )} onClick={handleClick} onDoubleClick={handleDoubleClick} onKeyDown={handleKeyDown} onContextMenu={handleContextMenu} > - {accessibleTitle} - + {accessibleTitle} + {inFolder ? null : ( + + {props.project ? ( + + ) : null} + )} - > - {props.project ? ( - + {draftIndicator} + {title} + {multiline ? titleLineProviderStack : null} + {tabCountBadge} + {agentIndicator} + {pinIndicator} + {props.inSplit ? : null} + {multiline ? null : terminalStatusIcon} + {savedDraftsIndicator} + {isRegeneratingTitle ? ( + + Regenerating title + ) : null} - - {draftIndicator} - {title} - {tabCountBadge} - {agentIndicator} - {pinIndicator} - {props.inSplit ? : null} - {terminalStatusIcon} - {savedDraftsIndicator} - {isRegeneratingTitle ? ( - - Regenerating title - - ) : null} - {prBadge} - {sortable?.isDragging ? ( - dragDestination - ) : ( - + {multiline ? null : prBadge} + {sortable?.isDragging ? ( + dragDestination + ) : ( - {variantAction === "unsnooze" && props.snoozeWakeLabelText !== null ? ( - - {props.snoozeWakeLabelText} - - ) : isWoke ? ( + + {variantAction === "unsnooze" && props.snoozeWakeLabelText !== null ? ( + + {props.snoozeWakeLabelText} + + ) : (inFolder ? isWokeStatus : isWoke) ? ( + + + + Woke + + } + /> + Dismiss Woke notification + + ) : inFolder && topStatus ? ( + // Folder rows have no status line, so the status takes the time slot. + + + {topStatus.label} + + ) : ( + + + + )} + + {variantAction === "unhide" ? ( - - Woke - + aria-label="Unhide thread" + onClick={handleUnhideClick} + className={cn( + "pointer-events-none absolute inset-y-0 right-0 -mr-1 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-1.5 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 group-any-hover/sidebar-row:pointer-events-auto group-any-hover/sidebar-row:opacity-100", + isWoke && "group-any-hover/sidebar-row:static", + )} + /> } - /> - Dismiss Woke notification + > + + + Unhide thread - ) : ( - - + + + ) + ) : !canOperateThread || !props.settlementSupported ? null : variantAction === + "unsettle" ? ( + + } - /> - - )} - - {variantAction === "unhide" ? ( - - - } - > - - - Unhide thread - - ) : variantAction === "unsnooze" ? ( - !props.snoozeSupported ? null : ( + > + + + Un-settle thread + + ) : ( - ) - ) : !canOperateThread || !props.settlementSupported ? null : variantAction === - "unsettle" ? ( - - - } - > - - - Un-settle thread - - ) : ( - - )} - - )} - {props.sweepAction !== null ? dragDestination : null} + )} + + )} + {props.sweepAction !== null ? dragDestination : null} +
  • + {multiline ? metaLine : null} {props.jumpLabel ? : null}
    } @@ -1181,7 +1280,6 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP ); } - const diff = latestRunDiff(thread); return (
  • 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/partitionThreads.test.ts b/apps/web/src/components/sidebar/partitionThreads.test.ts index 4c8dc60741fd8..d6bf011d43efd 100644 --- a/apps/web/src/components/sidebar/partitionThreads.test.ts +++ b/apps/web/src/components/sidebar/partitionThreads.test.ts @@ -24,6 +24,7 @@ it("classifies groups and Automations while keeping unscoped threads in filter c scopedProjectKeys: new Set([`${environmentId}:${projectId}`]), optimisticDrop: null, workingShelfEnabled: false, + sortActiveBySend: false, clockKey: "fixture", inboxReturns: createInboxReturnTracker(), }); diff --git a/apps/web/src/components/sidebar/partitionThreads.ts b/apps/web/src/components/sidebar/partitionThreads.ts index 4dd742d4bd387..3c0003ad19262 100644 --- a/apps/web/src/components/sidebar/partitionThreads.ts +++ b/apps/web/src/components/sidebar/partitionThreads.ts @@ -43,6 +43,8 @@ export function partitionSidebarThreads(input: { scopedProjectKeys: ReadonlySet | null; optimisticDrop: SidebarOptimisticDrop | null; workingShelfEnabled: boolean; + /** Boring mode: folders list active threads by the user's latest message. */ + sortActiveBySend: boolean; clockKey: string; delegatedUnderParent?: ReadonlySet; inboxReturns: ReturnType; @@ -55,6 +57,7 @@ export function partitionSidebarThreads(input: { scopedProjectKeys, optimisticDrop, workingShelfEnabled, + sortActiveBySend, inboxReturns, delegatedUnderParent, } = input; @@ -149,9 +152,11 @@ export function partitionSidebarThreads(input: { // sort, or mixed-version fleets would render different pinned orders on // web and mobile from the same data. const sortedPinned = sortPinnedThreadsForSidebar(pinned); - const sortedActive = workingShelfEnabled - ? sortInboxThreadsByReturn(active, inboxObservation.returnedAt) - : sortThreadsForSidebar(active); + const sortedActive = sortActiveBySend + ? sortWorkingThreadsBySend(active) + : workingShelfEnabled + ? sortInboxThreadsByReturn(active, inboxObservation.returnedAt) + : sortThreadsForSidebar(active); return { pinnedThreads: optimisticDrop?.section !== "pinned" || optimisticDrop.order === null diff --git a/apps/web/src/components/threadActionMenu.logic.test.ts b/apps/web/src/components/threadActionMenu.logic.test.ts index 9b04f74720abf..3d6071769fcd5 100644 --- a/apps/web/src/components/threadActionMenu.logic.test.ts +++ b/apps/web/src/components/threadActionMenu.logic.test.ts @@ -364,11 +364,14 @@ describe("buildThreadActionMenuItems", () => { ).toContain("archive"); }); - 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 61da9012c5a90..81bb2ceb4f17b 100644 --- a/apps/web/src/components/threadActionMenu.logic.ts +++ b/apps/web/src/components/threadActionMenu.logic.ts @@ -10,6 +10,7 @@ export type ThreadActionMenuId = | "close-split" | "new-thread-on-branch" | "filter-by-project" + | "turn-into-project" | "project-settings" | "pin" | "unpin" @@ -157,6 +158,8 @@ export interface ThreadActionMenuState { }; /** 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. */ @@ -308,6 +311,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 8a88dd1730e10..b21f6e289b5a3 100644 --- a/apps/web/src/components/ui/menu.tsx +++ b/apps/web/src/components/ui/menu.tsx @@ -175,7 +175,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 54792521b9c35..99908068f44fa 100644 --- a/apps/web/src/contextMenuFallback.ts +++ b/apps/web/src/contextMenuFallback.ts @@ -203,6 +203,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 f8e6040f0cb86..16313f6758e6d 100644 --- a/apps/web/src/hooks/useThreadActionMenu.test.ts +++ b/apps/web/src/hooks/useThreadActionMenu.test.ts @@ -157,6 +157,7 @@ vi.mock("./useSettings", () => ({ })); vi.mock("./useThreadActions", () => ({ readThreadSubscriptionLimitGuard: () => null, + canTurnThreadIntoProject: () => false, useThreadActions: () => Object.fromEntries( [ diff --git a/apps/web/src/hooks/useThreadActionMenu.ts b/apps/web/src/hooks/useThreadActionMenu.ts index bc990b4ef833a..140afeafecbb4 100644 --- a/apps/web/src/hooks/useThreadActionMenu.ts +++ b/apps/web/src/hooks/useThreadActionMenu.ts @@ -69,7 +69,11 @@ import { threadRuntimeCanArchive } from "@t3tools/client-runtime/state/models"; import { useCopyToClipboard } from "./useCopyToClipboard"; import { useNewThreadHandler } from "./useHandleNewThread"; import { useClientSettings } from "./useSettings"; -import { readThreadSubscriptionLimitGuard, useThreadActions } from "./useThreadActions"; +import { + canTurnThreadIntoProject, + readThreadSubscriptionLimitGuard, + useThreadActions, +} from "./useThreadActions"; import { useThreadTabActions } from "../components/chat/ThreadTabs"; function failureToast(title: string, error: unknown) { @@ -117,6 +121,7 @@ export function useThreadActionMenu(input: { archiveThread, deleteThread, markThreadUnread, + turnThreadIntoProject, } = useThreadActions(); const { groups, saveGroup } = useThreadGroups(); const updateThreadMetadata = useOrchestrationCommand(threadEnvironment.updateMetadata, { @@ -198,6 +203,7 @@ export function useThreadActionMenu(input: { snoozePresets, integrations: readEnvironmentLinkIntegrations(threadRef.environmentId), canShareWithGuest, + canTurnIntoProject: canTurnThreadIntoProject(threadRef), }); const clicked = await settlePromise(() => api.contextMenu.show(items, position)); if (clicked._tag === "Failure" || clicked.value === null) return; @@ -347,6 +353,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 fd4526a7708c6..1f9d144478aa3 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -35,6 +35,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"; @@ -260,6 +261,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 @@ -323,6 +338,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, @@ -1055,5 +1073,10 @@ export function useThreadActions() { setThreadHidden, setThreadGroup, confirmAndMoveThreadToProject, + turnThreadIntoProject: (target: ScopedThreadRef) => + createProjectFromThread({ + environmentId: target.environmentId, + input: { threadId: target.threadId }, + }), }; } diff --git a/apps/web/src/index.css b/apps/web/src/index.css index ce34e59eaeab6..c738499905925 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -2360,14 +2360,13 @@ code { } } -/* Diagnostics-style tables: row separators only, uppercase headers, and a - scroll-fade container for horizontal overflow. The root chat-markdown - wrapping rules (overflow-wrap: anywhere) would let columns shrink to single - characters and defeat the overflow — restore word-boundary wrapping so the - min column width is the longest word. */ +/* Diagnostics-style tables: row separators only, and a scroll-fade container + for horizontal overflow. Cells wrap and the table fits its lane. The root + chat-markdown wrapping rules (overflow-wrap: anywhere) would let columns + shrink to single characters, so restore word-boundary wrapping: the min + column width is the longest word, and only wider content scrolls. */ .chat-markdown table { width: 100%; - min-width: max-content; border-collapse: collapse; overflow-wrap: normal; word-break: normal; @@ -2397,48 +2396,21 @@ code { border-bottom: 1px solid color-mix(in srgb, var(--contrast-border) 60%, transparent); padding-block: 0.55rem; font-weight: 600; - white-space: nowrap; } .chat-markdown tbody td { border-bottom: 1px solid color-mix(in srgb, var(--contrast-border) 60%, transparent); } -/* Collapsed (default): single-line cells truncate so arbitrary chat content - cannot blow the column out; the footer toggle expands cells to wrap. */ -.chat-markdown .chat-markdown-table-container[data-expanded="false"] th, -.chat-markdown .chat-markdown-table-container[data-expanded="false"] td { - overflow: hidden; +/* Caps a long path, URL, or chip at 24rem; text past the cap wraps. */ +.chat-markdown :is(th, td) { max-width: 24rem; - text-overflow: ellipsis; - white-space: nowrap; -} - -.chat-markdown .chat-markdown-table-container[data-expanded="true"] td { - max-width: 24rem; - overflow-wrap: anywhere; -} - -/* Fit expanded tables to wide timeline lanes; narrow lanes keep horizontal - scrolling so prose columns remain readable. */ -@container chat-lane (min-width: 36rem) { - .chat-markdown .chat-markdown-table-container[data-expanded="true"] table { - min-width: 0; - } - - .chat-markdown .chat-markdown-table-container[data-expanded="true"] th { - white-space: normal; - } - - .chat-markdown .chat-markdown-table-container[data-expanded="true"] td { - overflow-wrap: break-word; - } + overflow-wrap: break-word; } /* Formula cells keep their natural width and scroll with the table. */ -.chat-markdown .chat-markdown-table-container td:has([data-markdown-math]) { +.chat-markdown :is(th, td):has([data-markdown-math]) { max-width: none; - overflow: visible; } :root { diff --git a/apps/web/src/lib/sidebarTree.browser.test.ts b/apps/web/src/lib/sidebarTree.browser.test.ts index 3d57aacb86438..6b02cd3b842bf 100644 --- a/apps/web/src/lib/sidebarTree.browser.test.ts +++ b/apps/web/src/lib/sidebarTree.browser.test.ts @@ -172,6 +172,25 @@ describe("sidebar keyboard navigation", () => { expect([firstAction.tabIndex, secondAction.tabIndex]).toEqual([-1, 0]); }); + it("keeps the scroll position when focus follows a row that moved away", async () => { + using tree = setup(); + Object.assign(tree.root.style, { height: "100px", overflow: "auto" }); + const settled = tree.row("settled"); + const settle = document.createElement("button"); + settle.textContent = "Settle"; + settled.after(settle); + settle.dataset.sidebarActionFor = "settled"; + for (let index = 0; index < 20; index++) tree.row(`row-${index}`).style.display = "block"; + tree.install(); + settle.focus(); + // Settling moves the row to the shelf at the bottom and removes the focused button. + settle.remove(); + settled.remove(); + const moved = tree.row("settled"); + await vi.waitFor(() => expect(document.activeElement).toBe(moved)); + expect(tree.root.scrollTop).toBe(0); + }); + it("does not pull focus back after a click outside the tree", async () => { using tree = setup(); const first = tree.row("first"); diff --git a/apps/web/src/lib/sidebarTree.ts b/apps/web/src/lib/sidebarTree.ts index 13dd10340e0b9..07859c72bd901 100644 --- a/apps/web/src/lib/sidebarTree.ts +++ b/apps/web/src/lib/sidebarTree.ts @@ -120,7 +120,8 @@ export function installSidebarTree(root: HTMLElement) { !rows.includes(document.activeElement as HTMLElement) && document.activeElement?.matches(ROW))) ) { - focusRow(selected); + // A settled row moves to the bottom shelf; scrolling after it fights the route reveal. + selected.focus({ preventScroll: true }); } if (ownsFocus && !selected && document.activeElement === document.body) root.focus(); }; 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 abd775465e7e1..75ce4837675dd 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 de213de2f3c1c..9c4e38c30e510 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -557,6 +557,20 @@ describe("V2 session presentation", () => { if (threadCreatedEntry?.kind === "work") { expect(threadCreatedEntry.entry.projectedItem?.item.type).toBe("thread_created"); } + expect( + deriveTimelineEntriesFromVisibleTurnItems({ + visibleTurnItems: [ + { + ...visibleTurnItems.at(-1)!, + item: { + ...workspacePreparationItem, + deferredWork: { cursor: "work-page", itemCount: 100, lastOrdinal: 107 }, + }, + }, + ], + optimisticMessages: [], + }).map((entry) => entry.id), + ).toEqual([workspacePreparationItem.id]); }); it.each(["pending", "running", "completed"] as const)( @@ -1458,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 4cb2fc7310e01..d8d3bd93127d5 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -608,7 +608,7 @@ export function deriveTimelineEntriesFromVisibleTurnItems( ); for (const row of input.visibleTurnItems) { const { item } = row; - if (turnItemIsWorkspacePreparation(item)) continue; + if (turnItemIsWorkspacePreparation(item) && !item.deferredWork) continue; if (item.type === "checkpoint") continue; if ( item.type === "user_message" && @@ -910,6 +910,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, @@ -950,15 +971,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) @@ -974,6 +1025,7 @@ function reuseTimelineEntries( }, }; }); + if (rederivedCount !== rederivedEntries.size) return null; if (appended) { entries.push( ...deriveTimelineEntriesFromVisibleTurnItems({ diff --git a/apps/web/src/sidebarProjectGrouping.ts b/apps/web/src/sidebarProjectGrouping.ts index ceef688971126..84bf147af95d4 100644 --- a/apps/web/src/sidebarProjectGrouping.ts +++ b/apps/web/src/sidebarProjectGrouping.ts @@ -84,17 +84,6 @@ export function resolveSidebarProjectScopeKey(input: { return group?.projectKey ?? (input.canClearMissingScope ? null : input.key); } -export function resolveSidebarProjectScopeKeys(input: { - readonly groups: ReadonlyArray; - readonly keys: ReadonlyArray; - readonly settings?: ProjectGroupingSettings; - readonly canClearMissingScope?: boolean; -}): string[] { - return [ - ...new Set(input.keys.flatMap((key) => resolveSidebarProjectScopeKey({ ...input, key }) ?? [])), - ]; -} - export function buildPhysicalToLogicalProjectKeyMap(input: { projects: ReadonlyArray; settings: ProjectGroupingSettings; 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/docs/internals/devices.md b/docs/internals/devices.md index 0f5f1556d850b..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 @@ -93,9 +114,24 @@ already-running agent without the CLI. How to drive a device is returned from `device_open`, not kept in an always-loaded prompt or skill: it costs nothing in threads that never open a -device and cannot drift from the pinned CLI version. The always-on prompt block -is a few lines that point at the tools and prefer them over raw `simctl` and -`adb`, which stay available for what the tools do not cover. +device and cannot drift from the pinned CLI version. `device_open` returns a +launcher bound to its agent-device session, so agents need no target flags. +The launcher leaves out the flags a command refuses (`cdp` talks to Metro, not +to the device), refuses the commands that would replace T3's connection, and +checks that connection first: agent-device reports a sandboxed shell and a +dropped tunnel alike as an unreachable daemon, so the launcher names which one it +is. Ending a session (`device_close`, the Device panel, a shutdown) also closes +its agent-device session, which quits the app that session opened and releases the CLI's claim on the device. + +Agents choose between these tools and ssh before they open anything, so +sessions that hold `device` get a short block from +[`orchestrationInstructions.ts`](../../packages/provider-core/src/server/orchestrationInstructions.ts) +through their adapter's instruction channel. It points simulator and emulator UI +work on local and SSH hosts at the tools; `simctl`, `adb` and ssh stay +available for what the tools do not cover. Pi and Muse do not send it yet. +Claude's native subagents never see the session's system prompt, so a +`SubagentStart` hook passes them the block, where the runtime runs hooks and the +subagent's context is not isolated. ## The viewer decodes both vendored protocols diff --git a/docs/internals/open-source-licenses.md b/docs/internals/open-source-licenses.md index ef7a6dba42966..2b40e5b52dc4c 100644 --- a/docs/internals/open-source-licenses.md +++ b/docs/internals/open-source-licenses.md @@ -25,11 +25,10 @@ Expo and React Native. That is conservative and can include build tooling that i the final JavaScript bundle, but it avoids dropping a notice when platform bundling changes. The build fails when a collected package has no distributable license identifier or contains no -license or notice text. Generated notices use license templates from the pinned SPDX License List. -Strict web and EAS builds download a missing template into the gitignored `.generated/` cache; -`pnpm licenses:sync` can warm that cache explicitly. Local web and Metro development do not make a -network request and omit generated rows until the cache exists. This keeps dev startup optional -while preventing incomplete release artifacts. +license or notice text. Generated notices use license templates from the pinned SPDX License List, +committed under `third-party-licenses/spdx/`. Builds never fetch them, so a network blip cannot +fail a build. A build fails when a configured template is missing; run `vp run licenses:sync` to +download it, then commit the new file. ## Custom notices and package overrides @@ -103,6 +102,5 @@ The `@react-grab/cli` override uses the root React Grab repository's MIT license npm archive omits both its license field and license file. Keep the override until the published CLI package carries that metadata itself. -Generated mobile files live under `apps/mobile/.generated/`, while fetched SPDX templates live -under the repository `.generated/` directory. Both are ignored. Do not commit or edit them; -updating dependencies or configuration is enough for the next strict build to refresh the output. +Generated mobile files live under `apps/mobile/.generated/` and are ignored. Do not commit or edit +them; updating dependencies or configuration is enough for the next build to refresh the output. diff --git a/docs/internals/performance-regressions.md b/docs/internals/performance-regressions.md index 51760756c1085..4458f1af93461 100644 --- a/docs/internals/performance-regressions.md +++ b/docs/internals/performance-regressions.md @@ -15,7 +15,9 @@ The v2 checks pin these invariants: - Cold opens and history pages stop at about 1 MiB of encoded rows. A window that holds a user turn also stops at 10 user turns (20 on older pages) or 500 rows, so ordinary turns stay whole. Any other window, including the inside of one turn longer than 500 rows, stops at 75 rows. The - snapshot query applies the same rule before it decodes payloads. Required control records and + snapshot query applies the same rule before it decodes payloads. Clients can opt into deferring + large work groups until expansion. Messages and items outside those groups remain in normal + history paging. Required control records and the at-least-one-row rule can exceed the byte target; it is not a hard cap. - The bounded snapshot does not retain the full duplicate conversation-message table. - Older activity is fetched in bounded HTTP pages and merged without disturbing the live scroll @@ -32,8 +34,9 @@ The v2 checks pin these invariants: projects small can increase server allocations on repeated large tool updates. - Raw command output, dynamic-tool result bodies, and inline file-change bodies are omitted at the wire boundary, including small outputs. Explicit failure flags and bounded result IDs - preserve status and grouped action counts without shipping those bodies. Subagent prompts and - results, reasoning text, command lines, search results, and checkpoint file lists still ship. + preserve status and grouped action counts without shipping those bodies. Reasoning text, + command lines, and search results ship when their work group loads. Subagent prompts and results + and checkpoint control records still accompany the initial window. Persisted events remain complete; the existing diff endpoints still provide file content when requested. Explicit cross-environment transcript exports preserve full tool bodies so the destination 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 e1d0ae1ac398d..c9cb451c90745 100644 --- a/docs/operations/development.md +++ b/docs/operations/development.md @@ -164,7 +164,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/orchestration-v2/orchestrator-mcp-server.md b/docs/orchestration-v2/orchestrator-mcp-server.md index ad7b13c10b293..19a84ea8dc848 100644 --- a/docs/orchestration-v2/orchestrator-mcp-server.md +++ b/docs/orchestration-v2/orchestrator-mcp-server.md @@ -410,7 +410,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 13d85e1c3e823..8ade17d6fa7da 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 48b373989ca1c..c38584bd0eef0 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/keybindings.md b/docs/user/keybindings.md index 11293aa26b9b0..80e7f4d2ab620 100644 --- a/docs/user/keybindings.md +++ b/docs/user/keybindings.md @@ -24,7 +24,7 @@ steer. Change it under **Queue: Send First Queued Message** in Keybindings. Use `mod+shift+m` to choose a model and `mod+shift+h` to choose a host. To step a new thread to the next machine instead of opening the menu, bind **Composer: Cycle Host** in Keybindings. It has no default shortcut. -Use `mod+shift+e` for effort, `mod+shift+a` for access mode, `mod+shift+x` for the +Use `mod+shift+e` for effort, `mod+shift+a` for permissions, `mod+shift+x` for the workspace, and `mod+shift+g` for the Git branch. The workspace menu includes the current checkout, a new worktree, and the previous worktree when available. Use `mod+shift+l` to reuse the previous worktree directly. `mod+alt+o` starts a new diff --git a/docs/user/permission-modes.md b/docs/user/permission-modes.md index 43e6e2670117b..bfd994aa7cc48 100644 --- a/docs/user/permission-modes.md +++ b/docs/user/permission-modes.md @@ -1,7 +1,9 @@ # Permission modes Permission modes control when an agent needs your approval to act. Choose a mode in the message -composer; it applies to that thread. +composer; it applies to that thread. A new thread names its mode under the composer. In a thread +on your default mode, open **Permissions** from the effort menu or press `mod+shift+a`. A thread on +another mode, or a model without an effort menu, shows the mode next to the model. Set the default for new threads in **Settings → Threads → New threads → Permissions**. Projects can override the environment default. New threads use this setting rather than the diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index 7f39df79a5a30..15f2ac0abf936 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: @@ -282,12 +282,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 @@ -301,7 +301,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 4aa0165ab6f58..8f3d34674236d 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -136,6 +136,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. + To move an existing conversation into another project, choose **Move to project** from its menu in the sidebar or the chat header (on mobile, press and hold the thread), or ask its agent to move it. The conversation stays intact and future work uses that project's main checkout; @@ -273,7 +279,8 @@ cannot drag or move threads within it. Your saved order returns when you turn it ### Group threads by project (beta) Turn on **Settings → Labs → Boring sidebar (group by project)** on web and desktop to put active -threads in a folder for each project. Click a folder to open or close it. A closed folder shows how +threads in a folder for each project. A project with no active threads has no folder until you +pick it in the project filter. Click a folder to open or close it. A closed folder shows how many threads it holds, and a dot when one of them needs you. Use the pen button on a folder, or right-click it, to start a thread in that project. Pinned threads stay above the folders, and Snoozed and Settled stay below them. In this mode you cannot drag threads, and the Working section diff --git a/docs/user/usage.md b/docs/user/usage.md index b581335251699..c191cb8fdaaee 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -126,6 +126,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, turn on **Settings → Labs → Usage in thread details** on web and desktop. +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/evals/README.md b/evals/README.md index a58291a54d11d..1aa5796b00378 100644 --- a/evals/README.md +++ b/evals/README.md @@ -4,11 +4,11 @@ An eval runs a real model against T3 Code's real text or code and scores what th Write or run an eval when a change affects what a model sees or does: agent instructions, MCP tool descriptions and schemas, tool results, generated titles, or the guidance an agent gets for a feature such as Device Manager. -| Eval | Measures | -| ------------------------------- | --------------------------------------------------------------------------- | -| [mcp-tool-use](mcp-tool-use/) | Whether agents use T3's MCP tools correctly in fake threads. | -| [thread-titles](thread-titles/) | Whether generated thread titles name the right subject. | -| [test-signal](test-signal/) | Whether a cheap model running the test-signal check flags low-signal tests. | +| Eval | Measures | +| ------------------------------- | ---------------------------------------------------------------------------------------------------- | +| [mcp-tool-use](mcp-tool-use/) | Whether agents use T3's MCP tools correctly, including device routing and recovery, in fake threads. | +| [thread-titles](thread-titles/) | Whether generated thread titles name the right subject. | +| [test-signal](test-signal/) | Whether a cheap model running the test-signal check flags low-signal tests. | ## Run an eval diff --git a/evals/mcp-tool-use/README.md b/evals/mcp-tool-use/README.md index e478b1fec1e5f..ef9ee77b4a4f4 100644 --- a/evals/mcp-tool-use/README.md +++ b/evals/mcp-tool-use/README.md @@ -16,7 +16,8 @@ pnpm mcp-tool-use --model claude-opus-5-5 --out runs/opus - Codex models (`gpt-*` in `MODELS` in `run.ts`) go through a CLIProxyAPI server, set up as the `vibeproxy` model provider in `~/.codex/config.toml`. Keep the proxy under about 12 samples in flight across all runs. - Flags: `--case `, `--cases `, `--samples ` (default 2, and a 3rd when the first 2 disagree), `--concurrency ` (samples in flight, default 8), `--max-steps `, `--effort `, `--dry-run` (prints request bodies, no model calls), and `--context` (prints the size of T3's text and schemas, no key needed). - `--baseline --stop-on-regression` stops as soon as a case that passed every baseline sample fails. Use it to test one copy change against a baseline run. -- Cost: a full Sonnet run is about $9, and a full Opus run about $17. Codex models through the proxy cost only the shell simulator. +- Cost: the existing non-device suite costs about $9 on Sonnet and $17 on Opus. The 16 device cases at 3 samples each cost about $5.70–5.90 on Opus and $2.90–3.40 on Sonnet (main's text–#25661), including the Haiku shell simulator; dry runs cost $0. Results record token costs for the agent and shell simulator. Codex proxy charges are outside that total; only its shell simulator is priced here, about $0.60–0.70 for the device cases. +- A rate limit or overload (429, 529 or another 5xx) is retried with backoff for up to about 4 minutes. A sample that still fails gets an `error` in `results.jsonl`, is left out of every pass rate, and is listed in the summary; the tokens it spent still count toward the cost. ## Add a case @@ -25,10 +26,88 @@ Cases are JSON lines in `cases/`, one file per area. The case schema in `run.ts` - `kind`: `bad` reproduces a mistake, `guard` protects correct behavior, `control` is ordinary work. - `experience`: the real mistake, in a sentence. `source`: where it happened. - `setup`: the fake thread, its `world` (repo, branch, files, PRs, pages), `priorTurns`, and `mocks` for tool results. -- `expect`: the checks, such as `calls`, `callsMustNot`, `maxCalls`, `order`, `textMust`, and `linked`. +- `expect`: the checks, such as `calls`, `callsMustNot`, `maxCalls`, `order`, `textMust`, and `linked`. Device cases also use `device` to check every child brief, forbid raw UI commands, bound failed device actions per user turn, and require artifact paths in completed final answers. Check that the case builds with `--dry-run --case `. Then run it on the text before your fix, where it must fail, and on the text after, where it must pass. +## Device manager cases + +`cases/device-manager.ndjson` ports nine mechanisms from the [device-routing investigation described in #25661](https://github.com/pingdotgg/t3code/pull/25661) and adds seven cases from t3dotgg’s 2026-10-26 fleet device investigation. All prompts, files, hosts and prior turns are authored Acme data; the fleet cases cite finding ids without carrying source transcripts or identifiers. + +| Case | Kind | What it checks | +| -------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| DEL-02 | bad | A delegated child uses managed recording start/stop and names the path in a completed final answer. | +| HOST-01a | guard | A known remote UDID opens successfully on the right host, including recovery through T3's host-correction error, then attempts managed capture and names the artifact in a completed final answer. | +| ORC-01 | bad | Every delegated or launched child brief routes UI through managed devices and keeps SSH for auxiliary work. | +| DOC-01 | bad | Lab documentation and locks do not redirect UI to SSH wrappers; successful open and managed capture attempt and final artifact path are required. | +| SEED-01 | bad | Prior SSH success does not determine the next UI channel; successful open and managed capture attempt and final artifact path are required. | +| NAT-01 | bad | A Claude native child gets product SubagentStart context, opens successfully and attempts managed capture and names the final artifact. | +| FAIL-AFTER-hub | guard | A seeded managed capture followed by outage leads to disclosure and at most two failed device attempts in this turn. | +| LEGIT-01 | control | A build-only task uses SSH without opening a device. | +| NODEV-02 | control | A repository explanation uses neither SSH nor devices. | + +| Case | Finding | Kind | What it checks | +| ------- | ------------- | ----- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| DISC-01 | F1/F9 (R6/R7) | guard | Failed discovery on build-mac is disclosed in the completed final answer; the authorized spare-mac target gets a successful managed open and capture attempt, with an artifact path. Allow the host-label lookup, all-host inventory and one refresh. At most two failed discovery/device attempts; no raw UI. | +| SESS-01 | F12 (R11) | bad | The exact returned launcher successfully opens com.acme.app before a successful app capture. A panel open, bare open, failed open or wrong app earns no app-session credit. SESSION_NOT_FOUND may recover within two failed actions; the completed final answer names the artifact. | +| ACC-01 | F8 | guard | A real agent-access denial stops device work without raw UI, launcher commands or delegation. The completed final answer explains that agent access is disabled and how to enable it in Settings > Integrations. | +| CAP-01 | F4 (R3) | guard | Zero-dimension Android capture metadata earns no visual proof. A successful managed capture may recover; otherwise the completed final answer reports the unusable image and unverified screen. At most two capture attempts across MCP and CLI; no raw UI. | +| OWN-01 | F3 (R4) | guard | An occupied target returns agent-device's real DEVICE_IN_USE error; this thread's panel open may succeed first. The authorized free target needs managed open/capture and a final target/artifact report. Commands naming the other owner's session and MCP shutdowns selecting the occupied panel session fail; harmless panel removal passes. | +| OWN-02 | F3 (R4) | guard | OWN-01 without the prompt's and context's no-interference hints. With a free target on offer, the agent takes it and leaves the other thread's session alone. Graded like OWN-01. | +| OWN-03 | F3 (R4) | bad | Within two failed device attempts, the agent reports that the device is in use and stops, or waits once with `--wait`; any command naming the owner's session (reuse or close) and an MCP shutdown of the device fail. Main's text never reproduced the takeover, so this guards F3 and the failure rule's stop discipline. | + +`device.report` requires every configured regex in a completed final answer, so planning text cannot satisfy failure disclosure. CAP-01 sets `device.reportOrCapture`: a successful positive-dimension MCP capture or an attributable successful returned-launcher screenshot can replace the blocked report. Zero dimensions, failed commands, quoted examples and unbound launchers earn no recovery credit. It is a bounded disclosure check, not a general honesty evaluator. `device.capture.app` requires a successful returned-launcher app open before an attributable successful screenshot; a later close or different app open removes app credit. An unambiguous `open … && screenshot` chain is processed in action order. Masked failures and accessibility snapshots earn no screenshot credit. CAP recovery requires an attributable successful managed screenshot: an unmasked call whose status proves success, or a unique screenshot success line or positive-dimension capture JSON naming its output. Later `ls`, `file` or exit-status output alone cannot establish freshness when `;` masks capture status. `device.maxCaptureAttempts` counts MCP screenshot calls and managed CLI screenshot invocations, sharing one retry limit across those channels. Accessibility snapshots are inspection and do not consume the PNG retry budget or establish a usable image. No new shell grammar is added. + +OWN-01 uses an authored agent-device ownership claim. Current T3 panel open has no cross-thread rejection; agent-device enforces its own DEVICE_IN_USE check. The case permits discovery of that contention and checks selection of a free target without reusing or closing the other owner’s CLI session; it does not claim T3 lease enforcement. DISC-01 covers an allowed alternate, not a fleet without one. CAP-01 models adb warning bytes before a PNG, not arbitrary blank or corrupt pixels; agent-device capture is unaffected. OWN-01 models same-daemon ownership, not cross-workspace claims. Access-off worlds remove the device capability and its instructions while T3 still lists the tools. Optional remote-install coverage (F2) remains outside this increment. + +### Results + +Passes per case, main's text → #25661, from one paired run of 3 samples per case on the same evaluator (OWN-03 in a follow-up run of the same evaluator), graded with this commit's grader. One main-text Astra SESS-01 sample errored at the provider and is left out. + +| Case | Opus 5.5 | Sonnet 5.5 | GPT-6.1 Sol | GPT-6 Astra | +| -------------- | --------- | ---------- | ----------- | ----------- | +| DEL-02 | 0/3 → 2/3 | 0/3 → 3/3 | 0/3 → 2/3 | 2/3 → 3/3 | +| HOST-01a | 3/3 → 3/3 | 3/3 → 3/3 | 2/3 → 3/3 | 2/3 → 1/3 | +| ORC-01 | 0/3 → 3/3 | 0/3 → 3/3 | 3/3 → 3/3 | 3/3 → 3/3 | +| DOC-01 | 1/3 → 3/3 | 0/3 → 3/3 | 3/3 → 3/3 | 1/3 → 3/3 | +| SEED-01 | 0/3 → 3/3 | 0/3 → 3/3 | 3/3 → 3/3 | 3/3 → 3/3 | +| NAT-01 | 0/3 → 3/3 | 0/3 → 3/3 | n/a → n/a | n/a → n/a | +| FAIL-AFTER-hub | 2/3 → 3/3 | 2/3 → 3/3 | 3/3 → 3/3 | 3/3 → 3/3 | +| LEGIT-01 | 3/3 → 3/3 | 3/3 → 3/3 | 3/3 → 3/3 | 3/3 → 3/3 | +| NODEV-02 | 3/3 → 3/3 | 3/3 → 3/3 | 3/3 → 3/3 | 3/3 → 3/3 | +| DISC-01 | 3/3 → 3/3 | 3/3 → 3/3 | 3/3 → 3/3 | 3/3 → 2/3 | +| SESS-01 | 2/3 → 3/3 | 3/3 → 3/3 | 3/3 → 3/3 | 2/2 → 3/3 | +| ACC-01 | 0/3 → 3/3 | 0/3 → 3/3 | 0/3 → 3/3 | 0/3 → 3/3 | +| CAP-01 | 2/3 → 3/3 | 3/3 → 3/3 | 3/3 → 3/3 | 3/3 → 3/3 | +| OWN-01 | 3/3 → 3/3 | 3/3 → 3/3 | 3/3 → 3/3 | 3/3 → 3/3 | +| OWN-02 | 2/3 → 3/3 | 3/3 → 3/3 | 3/3 → 3/3 | 3/3 → 3/3 | +| OWN-03 | 3/3 → 3/3 | 2/3 → 3/3 | 1/3 → 3/3 | 3/3 → 3/3 | + +Some results are artifacts of the simulated shell rather than the agent: dev-client loads that never finish (HOST-01a), refusals it invented for valid commands (DOC-01, DISC-01), compound commands that escaped FAIL-AFTER-hub's outage mock, and an invented recording result (DEL-02). They are counted as graded. SESS-01 and OWN-01/OWN-02 tell the agent what to do, so they guard behaviour rather than discriminate. + +Run a single case before a paid batch: + +```sh +pnpm mcp-tool-use --case HOST-01a --dry-run +pnpm mcp-tool-use --case HOST-01a --samples 1 --out runs/device-check +``` + +Device inventory, open, screenshot and close results come from this checkout's real DeviceToolkit handlers over scripted device state; only the hosts' availability text is the eval's own. Injected outages use product error classes. NAT-01 leaves out all of T3's system-prompt append, as a native subagent gets none of it, and adds the real Claude SubagentStart hook's additionalContext after the task, where Claude Code puts it. It replays the child's decision, without spawning an Agent process. Codex has no corresponding hook, so NAT-01 is excluded there. + +In device cases the shell simulator is told which launchers T3's `device_open` returned in the sample, so it never treats them as missing. It answers them from agent-device's own help, `agent-device-help.txt`: the `help`, `help commands`, `record`, `screenshot`, `open` and `settings` text of the version T3 installs (a test fails when that version changes), plus agent-device's error for an unknown command or flag. A lone help command for one of those topics, such as ` help record`, gets that text verbatim without a model call. The output of a valid command is still the simulator's invention. It does not model the base launcher's `--config`/`--session` check: every ref gets the same contract, with only its own launcher paths. + +The simulator models agent-device's session rule from the real-Mac transcript for #25661 and the pinned CLI source. `device_open` starts no agent-device session. The agent's first `open` does; `close` ends it. Before that, a plain screen or input command on a returned launcher, such as `screenshot`, `press` or `fill`, gets agent-device's `SESSION_NOT_FOUND` error and hint without a model call. A plain `record start` without `--scope device` gets agent-device's app-scope error before `open`, and another after an `open` with no app. `devices`, `apps`, `install`, `settings`, `alert` and `record start --scope device` need no session, because the command names the device. On an iOS device, `snapshot` (and `find`, `get`, `is`, `diff` and element `wait`) also needs an app opened in the session: before `open `, and after a bare `open`, it gets agent-device’s `iOS snapshot requires an active app session` error. A bare `open` prints `Opened: ` and does not identify an app. An Android `snapshot` can start a session without an app. Both refs name the session: the bound launcher does it itself, and the shared one gets the `--session` flag `device_open` returned. For compound commands the simulator applies the same rule, from each session's state in its description. Sessions follow the sample's shell history, prior turns included. A case can also declare sessions that prior turns started in `world.agentDeviceSessions`, as FAIL-AFTER-hub does for its earlier Settings check. + +`device.briefs` checks every `delegate_task.task` and `t3_thread_launch.message`, rejecting mixed routes such as “Use device_open. Use SSH to inspect the UI.” `device.noRawUi` checks shell commands by operation, including commands behind wrappers and literal programs fed to a shell or interpreter: simulator capture and UI control fail; SSH builds, installs, inventory, sync, Metro, forwards, logs, test runners and locks remain allowed. Auxiliary agent-device operations (`devices`, `apps`, `session list`, `doctor`, `capabilities`, installs, `logs`, `help`, `version`, and `record contact-sheet`) are excluded from raw UI and the failure budget; global option values are consumed before classifying the operation. The separate lab-wrapper rule above still applies. + +The recovery budget counts failed device actions across tools, managed launchers and raw routes; `device_list` and auxiliary commands do not consume it. An aggregate failure counts at least one device attempt; visible human or JSON command errors count up to the number of possible device actions in the call, even when a later command masks the exit status. Aggregate errors cannot always identify which operation failed. Endpoints and model steps do not reset the budget; seeded history is outside the measured turn. + +Raw-route checks inspect possible literal invocations; managed capture and recording require established execution, excluding dead short-circuit tails, uncalled definitions and uncertain branches. A failed screenshot without result metadata is attributed to the most recent successful still-open session for its device id, or the sole matching open device; ambiguous or unknown targets receive no remote credit. Successful result metadata remains authoritative. A launcher command earns capture credit only on the device it drives: the one its `--udid`, `--serial` or `--session` names, or its launcher's only device; with two devices open, a shared-launcher command that names neither earns none. Recording requires ordered start/stop with propagated success and no agent-device error in the call's output, or a unique operation-specific `Recording started`/`Recording stopped` result (including JSON `kind` results); an unrelated action's error does not erase an attributable success. If neither status nor an attributable success result establishes a recording outcome, it is uncredited, including silent compound or interpreter calls. A single-call `start; …; stop` needs both operation results unless the final status establishes the stop. `device.artifact` and recording paths must occur in `finalText` with `end_turn`; missing final-answer evidence receives no credit. Non-device `textMust` semantics are unchanged. + +Commands built at run time, external script files and full shell/interpreter semantics (including complex quoting, dynamic control flow and exception handling) are outside the bounded parser. Brief routing is a clause heuristic rather than a general natural-language parser. + +This API replay measures routing and recovery decisions. Screenshots return product text and metadata without rendered image blocks, and Codex code cells have no `image` helper, so a cell that calls it fails with `image is not defined`. The shell remains simulated, children are not executed, and `priorTurns` is synthetic context rather than native resume or compaction. It does not test launcher execution, binding, pruning, actual device state, transport failures or resource cleanup, and it models agent-device's session check rather than running it. DEL-02 checks managed recording start/stop and the final filename; it does not inspect video bytes, required flow, transforms, codec or size. Screenshots and final text do not establish delivered artifacts or honesty. Keep local real-harness results separate; their scores and costs are not API-replay results. Offline validation costs $0. Record fresh per-model baseline and candidate costs before using device-case scores as release evidence. + ## Where T3's text lives - `packages/provider-core/src/server/runtimeInstructions.ts` diff --git a/evals/mcp-tool-use/agent-device-help.txt b/evals/mcp-tool-use/agent-device-help.txt new file mode 100644 index 0000000000000..e987a0f37a68d --- /dev/null +++ b/evals/mcp-tool-use/agent-device-help.txt @@ -0,0 +1,262 @@ +agent-device 0.21.31 prints this help; apps/server/src/device/deviceToolVersions.json pins that version. Regenerate from +the repo root with: node scripts/node_modules/agent-device/bin/agent-device.mjs help [commands|record|screenshot|open|settings] + +$ agent-device help +agent-device [args] [--json] + +Automates iOS, Android, macOS, TV, and web apps for AI agents. +All 75 commands: agent-device help commands + +Start: + When starting a task with a known app, first run: + agent-device open --foreground + It starts the session and returns the initial interactive snapshot with @refs. + Do not probe first with devices, apps, appstate, snapshot, or screenshot. + Unknown app id: devices, then apps, then open . Never invent ids. + Resuming an existing session: continue from its current state; do not reopen it. + +Loop: + press|click|fill|longpress ... --settle + hover --settle (web only; reveals hover-gated UI) + scroll [amount] --settle; back --settle + acts, waits for quiet, and prints the UI diff. Continue from that diff. + Run snapshot -i only when the diff lacks the next target or did not settle. + Verify a named expectation with the diff, wait text "...", wait , wait absent , + is, get, or find. A bare screenshot is not verification. + End with: agent-device close + +Targets: + Copy refs exactly: @e12, @e12~s4. Keep @ and any ~sN pin; refs go stale + after mutations. A literal @handle is label="@handle", not a bare ref. + Prefer refs, then id/label/role selectors. Selector keys: id role text label value appname windowtitle visible hidden editable selected focused enabled hittable. + Coordinates are last resort: after snapshot -i shows no semantic target, or a + sparse/AX-unavailable warning says its refs and selectors are invalid. + Then screenshot, press , and re-snapshot on the changed screen. + +Rules: + --settle is only for press/click/fill/longpress/hover/scroll/back; never open, + snapshot, or close. type never accepts --settle. + fill --settle replaces; type appends after focus. + Late network/debounce result: wait text "Expected", not snapshot polling. + Output full agent-device commands; no pipes, grep, jq, or pseudo-commands. + Stop when the requested end state is visible. Mutations run serially. + +More commands (exact shapes: agent-device help ): + open install devices apps boot close app and device lifecycle + screenshot record logs network perf trace evidence and diagnostics + replay test batch session scripted flows + alert keyboard clipboard settings gesture system and input + +Guides (agent-device help ): + workflow full refs, selectors, waits, recovery, and platform limits + manual-qa / dogfood / validate / debugging / scripting / gestures + react-native / react-devtools / cdp / tv / web / macos / remote + physical-device / ios-system-ui / maestro + foldable iPhone Duo panels, the fold command, and why refs die on a pose change + + +$ agent-device help commands +agent-device 0.21.31 — commands + +Full command catalog. Use agent-device help for exact flags and behavior. + +Commands: + action-button Press the iPhone or iPad Action Button + alert [action] [timeout] Inspect, accept, or dismiss a platform alert + app-switcher Open the device app switcher + apps List installed apps or deferred provider app assets + appstate Show the foreground app and activity + artifacts [provider-session-id] List daemon or cloud provider session artifacts + audio Probe audio levels + auth Manage cloud login state + back Navigate back in the app or system + batch --steps | --steps-file Run multiple commands + boot Boot target device/simulator + capabilities List supported commands for the selected device + cdp Inspect CDP targets, JS heap, and leaks + click Click or tap a UI target + clipboard read | clipboard write Read or write device clipboard text + close [app] Close an app or end the session + connect Configure remote or provider state + connection Inspect the saved remote connection + daemon stop|status Stop a local daemon or show the providers it registered + debug Symbolicate Apple crash artifacts + device status Inspect and recover enforced local device ownership without a daemon + devices List available devices and simulators + diff [current] Diff snapshot or screenshot + disconnect Clear remote connection state and release leases + doctor Diagnose device, app, dev-server, and RN/Expo readiness + events Read session event timeline + fill [text] Replace text in a UI input + find [value] Find an element and act + focus Focus input at screen coordinates + fold [pose] Fold or unfold a foldable iPhone simulator or Android emulator + gesture ... Run pan, fling, swipe, pinch, rotate, transform, tap, or drag gestures + get Read element text or attributes + hardware-button