diff --git a/.agents/skills/test-t3-mobile/SKILL.md b/.agents/skills/test-t3-mobile/SKILL.md index fbcd52e697dd..3fcf94334fd6 100644 --- a/.agents/skills/test-t3-mobile/SKILL.md +++ b/.agents/skills/test-t3-mobile/SKILL.md @@ -80,7 +80,6 @@ Run Metro from `apps/mobile`. APP_VARIANT=development vp exec expo start \ --dev-client \ --scheme t3code-dev \ - --clear \ --lan \ --port ``` @@ -179,6 +178,7 @@ Keep local verification focused. Do not turn this workflow into a full repositor ## Troubleshoot predictable failures - **Old UI or an old error appears:** verify Metro's worktree, variant, URL, and port before diagnosing the app. +- **Metro serves stale or invalid transforms after those checks:** stop the owned Metro process and run `vp run dev:client:reset` once on the standard port. For a custom port, add `--clear` to the complete explicit `expo start` command above. - **The environment remains empty:** verify the platform-specific HTTP origin, use a fresh token, and confirm project seeding used the identical base directory. - **A second client cannot pair:** pairing tokens are single-use; issue another token. - **The pairing form opens but does not connect:** confirm the deep link uses the existing `connections/new` route, includes `autoConnect=1`, and carries a freshly minted encoded `pairingUrl`. diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 000000000000..6fc9f6f1a1f7 --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,4 @@ +reviews: + review_status: false + auto_review: + enabled: false diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index 3dacaf2a92a9..98cbc681b75d 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -9,6 +9,8 @@ # -github:username reason for denouncement # # Keep entries sorted alphabetically. +github:0x4bs3nt +github:Adamulek123 github:adityavardhansharma github:arhxam github:bil0000 @@ -26,6 +28,7 @@ github:github-actions[bot] github:gsimone github:GuilhermeVieiraDev github:hwanseoc +github:ipanasenko github:jakeleventhal github:jamesx0416 github:jappyjan @@ -37,7 +40,9 @@ github:lnieuwenhuis github:Lucenx9 github:mackinleysmith github:maria-rcks +github:maxwellyoung github:mwolson +github:nateEc github:nmggithub github:Noojuno github:notkainoa @@ -48,6 +53,7 @@ github:PollyGlot github:RakshithBhat03 github:realAhmedRoach github:Rishet11 +github:ryanrhughes github:saphid github:sethwebster github:shiroyasha9 @@ -56,6 +62,7 @@ github:StiensWout github:SunkenInTime github:tarik02 github:tris203 +github:tsouth89 github:UtkarshUsername github:Yash-Singh1 github:yashranaway diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 731707eed4d2..570abd7d0505 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,9 @@ on: branches: - main +permissions: + contents: read + concurrency: group: ci-${{ github.event.pull_request.number || github.sha }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} @@ -52,10 +55,7 @@ jobs: run: vp run build:desktop - name: Verify preload bundle output - run: | - test -f apps/desktop/dist-electron/preload.cjs - grep -nE "desktopBridge|getLocalEnvironmentBootstrap|PICK_FOLDER_CHANNEL|wsUrl" apps/desktop/dist-electron/preload.cjs - grep -n "__clerk_internal_electron_passkeys" apps/desktop/dist-electron/preload.cjs + run: node apps/desktop/scripts/verify-preload-bundle.mjs # Everything except `t3` (apps/server). `--parallel` drops the package # dependency ordering that `vp run` applies by default: these `test` tasks diff --git a/.github/workflows/desktop-macos-preview.yml b/.github/workflows/desktop-macos-preview.yml new file mode 100644 index 000000000000..7875aec6f36b --- /dev/null +++ b/.github/workflows/desktop-macos-preview.yml @@ -0,0 +1,361 @@ +name: Desktop macOS Preview + +on: + pull_request: + types: [labeled, unlabeled, synchronize, reopened, closed] + +permissions: + contents: read + +# Build events and cleanup events use separate groups: a push must cancel a +# stale in-flight build, but must never cancel a cleanup run mid-delete. The +# publish job re-checks PR state before uploading to cover the reverse race. +concurrency: + group: desktop-macos-preview-${{ github.event.pull_request.number }}-${{ contains(fromJSON('["closed", "unlabeled"]'), github.event.action) && 'cleanup' || 'build' }} + # Cleanup runs must complete (a close event right after an unlabel queues + # behind the running cleanup instead of canceling it mid-delete), and events + # that skip the build job, such as adding an unrelated label, must not + # cancel an in-flight build either. + cancel-in-progress: ${{ !contains(fromJSON('["closed", "unlabeled"]'), github.event.action) && (github.event.action != 'labeled' || github.event.label.name == 'preview:mac') }} + +jobs: + # Builds run PR code, so this job keeps a read-only token. Publishing to the + # release happens in the publish job below, which never checks out PR code. + build: + name: Build macOS Apple Silicon preview + if: >- + github.event.action != 'closed' && + github.event.action != 'unlabeled' && + github.event.pull_request.head.repo.full_name == github.repository && + contains(github.event.pull_request.labels.*.name, 'preview:mac') && + (github.event.action != 'labeled' || github.event.label.name == 'preview:mac') + runs-on: blacksmith-12vcpu-macos-26 + timeout-minutes: 30 + outputs: + dmg_name: ${{ steps.build.outputs.dmg_name }} + version: ${{ steps.version.outputs.version }} + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ github.event.pull_request.head.sha }} + sparse-checkout: | + /* + !/.repos/ + sparse-checkout-cone-mode: false + + - name: Setup Vite+ + uses: voidzero-dev/setup-vp@v1 + with: + node-version-file: package.json + cache: true + run-install: false + + - name: Install desktop dependencies + run: vp install --filter=@t3tools/desktop... --filter=t3... --filter=@t3tools/scripts... + + - name: Cache resource monitor + id: resource_monitor_cache + uses: actions/cache@v6 + with: + path: native/resource-monitor/target/aarch64-apple-darwin/release/t3-resource-monitor + key: resource-monitor-aarch64-apple-darwin-${{ hashFiles('native/resource-monitor/Cargo.lock', 'native/resource-monitor/Cargo.toml', 'native/resource-monitor/src/**') }} + + - name: Setup Rust + if: steps.resource_monitor_cache.outputs.cache-hit != 'true' + uses: dtolnay/rust-toolchain@stable + with: + targets: aarch64-apple-darwin + + - id: version + name: Set preview version and public configuration + shell: bash + env: + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + set -euo pipefail + + base_version="$(node -p "require('./apps/desktop/package.json').version")" + preview_version="${base_version}-pr.${PR_NUMBER}.${GITHUB_RUN_NUMBER}" + node scripts/update-release-package-versions.ts "$preview_version" + cp .env.example .env + + echo "version=$preview_version" >> "$GITHUB_OUTPUT" + + - id: build + name: Build unsigned macOS DMG + shell: bash + env: + T3CODE_DESKTOP_REUSE_RESOURCE_MONITOR: ${{ steps.resource_monitor_cache.outputs.cache-hit == 'true' }} + PREVIEW_VERSION: ${{ steps.version.outputs.version }} + run: | + set -euo pipefail + + vp run dist:desktop:artifact \ + --platform mac \ + --target dmg \ + --arch arm64 \ + --build-version "$PREVIEW_VERSION" \ + --verbose + + shopt -s nullglob + dmg_files=(release/*.dmg) + if (( ${#dmg_files[@]} != 1 )); then + printf 'Expected one DMG, found %s.\n' "${#dmg_files[@]}" >&2 + exit 1 + fi + printf 'dmg_name=%s\n' "$(basename "${dmg_files[0]}")" >> "$GITHUB_OUTPUT" + + # archive: false uploads the file as its own artifact named after the + # file, so the publish job downloads by *.dmg pattern, not by name. + - name: Upload macOS DMG + uses: actions/upload-artifact@v7 + with: + path: release/*.dmg + if-no-files-found: error + archive: false + overwrite: true + retention-days: 7 + + # Release assets download without a GitHub account, unlike workflow + # artifacts. All preview DMGs live on one rolling prerelease tagged + # "desktop-preview" (release.yml only matches v*.*.* tags), so publishing a + # build never notifies release watchers. This job holds the write token and + # only handles the artifact the build job produced; it never runs PR code. + publish: + name: Publish anonymous download + needs: build + runs-on: blacksmith-8vcpu-ubuntu-2404 + timeout-minutes: 10 + permissions: + contents: write + pull-requests: write + steps: + - name: Download macOS DMG + uses: actions/download-artifact@v8 + with: + pattern: "*.dmg" + merge-multiple: true + path: release + + - id: upload + name: Upload DMG to the rolling preview release + shell: bash + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + run: | + set -euo pipefail + + tag="desktop-preview" + + # True while the PR is open and still carries the preview label. + preview_eligible() { + [[ "$(gh pr view "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" \ + --json state,labels \ + --jq '.state + " " + (.labels | map(.name) | contains(["preview:mac"]) | tostring)')" == "OPEN true" ]] + } + + # The build ran for many minutes. If the PR closed or lost the label + # meanwhile, cleanup already ran in its own concurrency group, so + # publishing now would resurrect a deleted download. + if ! preview_eligible; then + echo "PR closed or preview label removed while building. Skipping publish." + exit 0 + fi + + dmg_path="$(find release -type f -name '*.dmg' -print -quit)" + if [[ -z "$dmg_path" ]]; then + echo "No DMG found in the downloaded artifact." >&2 + exit 1 + fi + + # The filename comes out of the build, which runs PR code. Requiring + # this PR's marker keeps a build from clobbering or deleting another + # PR's asset, since those names carry a different -pr.N. marker. + if [[ "$(basename "$dmg_path")" != *"-pr.${PR_NUMBER}."* ]]; then + echo "DMG name '$(basename "$dmg_path")' does not carry this PR's -pr.${PR_NUMBER}. marker. Refusing to publish." >&2 + exit 1 + fi + + if ! gh release view "$tag" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + # "|| true" tolerates a concurrent publish job creating the + # release between the check and the create. + gh release create "$tag" \ + --repo "$GITHUB_REPOSITORY" \ + --target "$DEFAULT_BRANCH" \ + --prerelease \ + --title "Desktop preview builds" \ + --notes "Rolling unsigned desktop builds from pull requests with a preview label. Each download is removed when its pull request closes or loses the label. Install stable builds from the latest release instead." \ + || true + fi + + # Keep one DMG per PR: drop this PR's older builds first. The + # trailing dot keeps -pr.12. from matching -pr.123. builds. + gh release view "$tag" --repo "$GITHUB_REPOSITORY" --json assets --jq '.assets[].name' \ + | { grep -F -- "-pr.${PR_NUMBER}." || true; } \ + | while read -r asset; do + gh release delete-asset "$tag" "$asset" --repo "$GITHUB_REPOSITORY" --yes \ + || echo "Asset $asset was already removed by a concurrent run." + done + + gh release upload "$tag" "$dmg_path" --repo "$GITHUB_REPOSITORY" --clobber + + # Re-check after uploading. A cleanup run that started during the + # upload listed assets before ours existed, so it cannot delete it. + # Whichever writer acts last sees the final PR state; if the preview + # became ineligible, delete what we just uploaded. + if ! preview_eligible; then + gh release delete-asset "$tag" "$(basename "$dmg_path")" --repo "$GITHUB_REPOSITORY" --yes \ + || echo "Asset was already removed by a concurrent run." + echo "PR closed or preview label removed during upload. Removed the download." + exit 0 + fi + + echo "download_url=https://github.com/${GITHUB_REPOSITORY}/releases/download/${tag}/$(basename "$dmg_path")" >> "$GITHUB_OUTPUT" + + - name: Comment download link + if: steps.upload.outputs.download_url != '' + uses: actions/github-script@v8 + env: + DOWNLOAD_URL: ${{ steps.upload.outputs.download_url }} + DMG_NAME: ${{ needs.build.outputs.dmg_name }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + PREVIEW_VERSION: ${{ needs.build.outputs.version }} + with: + script: | + const { data: pullRequest } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.payload.pull_request.number, + }); + if ( + pullRequest.head.sha !== process.env.HEAD_SHA || + pullRequest.state !== "open" || + !pullRequest.labels.some((label) => label.name === "preview:mac") + ) { + core.info("Skipping the outdated macOS preview comment."); + return; + } + + const marker = ""; + const body = [ + marker, + "### macOS preview", + "", + `[Download Apple Silicon DMG](${process.env.DOWNLOAD_URL})`, + "", + `Version: ${process.env.PREVIEW_VERSION}`, + `Commit: ${process.env.HEAD_SHA.slice(0, 7)}`, + "", + "Unsigned build. Clear quarantine before opening:", + "```sh", + `xattr -d com.apple.quarantine ~/Downloads/${process.env.DMG_NAME}`, + "```", + "", + "No GitHub sign-in is needed. The download stays available until this PR closes or the preview label is removed.", + ].join("\n"); + + const comments = await github.paginate(github.rest.issues.listComments, { + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.pull_request.number, + per_page: 100, + }); + const existing = comments.find((comment) => comment.body?.includes(marker)); + + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.pull_request.number, + body, + }); + } + + # The way out: closing the PR or removing the label deletes its DMG from the + # rolling release and updates the PR comment to say so. + cleanup: + name: Remove preview download + if: >- + github.event.pull_request.head.repo.full_name == github.repository && + ((github.event.action == 'closed' && contains(github.event.pull_request.labels.*.name, 'preview:mac')) || + (github.event.action == 'unlabeled' && github.event.label.name == 'preview:mac')) + runs-on: blacksmith-8vcpu-ubuntu-2404 + timeout-minutes: 10 + permissions: + contents: write + pull-requests: write + steps: + - id: delete + name: Delete this PR's preview assets + shell: bash + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + set -euo pipefail + + tag="desktop-preview" + + # A stale cleanup must not delete a download that became valid + # again. If the PR is open and labeled once more, the next publish + # owns this PR's assets and replaces them itself. + if [[ "$(gh pr view "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" \ + --json state,labels \ + --jq '.state + " " + (.labels | map(.name) | contains(["preview:mac"]) | tostring)')" == "OPEN true" ]]; then + echo "PR is open and labeled again. Skipping cleanup." + echo "removed=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + echo "removed=true" >> "$GITHUB_OUTPUT" + + if ! gh release view "$tag" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + echo "No preview release exists. Nothing to clean up." + exit 0 + fi + + gh release view "$tag" --repo "$GITHUB_REPOSITORY" --json assets --jq '.assets[].name' \ + | { grep -F -- "-pr.${PR_NUMBER}." || true; } \ + | while read -r asset; do + gh release delete-asset "$tag" "$asset" --repo "$GITHUB_REPOSITORY" --yes \ + || echo "Asset $asset was already removed by a concurrent run." + done + + - name: Mark the preview comment as removed + if: steps.delete.outputs.removed == 'true' + uses: actions/github-script@v8 + with: + script: | + const marker = ""; + const comments = await github.paginate(github.rest.issues.listComments, { + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.pull_request.number, + per_page: 100, + }); + const existing = comments.find((comment) => comment.body?.includes(marker)); + if (!existing) { + return; + } + + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body: [ + marker, + "### macOS preview", + "", + "The preview download was removed because this PR closed or the preview label was removed.", + ].join("\n"), + }); diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index df41129960bc..4a7e3a9b9253 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,7 +6,8 @@ on: - "v*.*.*" - "!v*-nightly.*" schedule: - - cron: "0 */3 * * *" + # Off minute zero: GitHub delays scheduled runs most at the top of the hour. + - cron: "38 */3 * * *" workflow_dispatch: inputs: channel: @@ -22,6 +23,17 @@ on: required: false type: string +# Serialize nightlies (scheduled and manual) so overlapping runs cannot build +# the same commit twice or publish out of order. Stable tag releases get their +# own group so a nightly never blocks them. Running publishers are never +# canceled, and queue: max keeps every pending run instead of the default +# newest-wins single slot, so a queued stable tag can never be silently +# dropped. Queued nightlies with no new commits skip via check_changes. +concurrency: + group: release-${{ (github.event_name == 'schedule' || inputs.channel == 'nightly') && 'nightly' || 'stable' }} + cancel-in-progress: false + queue: max + permissions: contents: read id-token: none @@ -199,8 +211,14 @@ jobs: relay_public_config: name: Resolve T3 Connect public config - needs: preflight - if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' }} + # Consumes only the commit SHA, not preflight's resolved version, so it runs + # alongside preflight instead of after it. The condition mirrors preflight's: + # check_changes is skipped on non-schedule events (skipped is neither failure + # nor success, so success() would be wrong here). + needs: [check_changes] + if: | + !failure() && !cancelled() && + (github.event_name != 'schedule' || needs.check_changes.outputs.has_changes == 'true') runs-on: blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 5 environment: @@ -222,7 +240,7 @@ jobs: - name: Checkout uses: actions/checkout@v6 with: - ref: ${{ needs.preflight.outputs.ref }} + ref: ${{ github.sha }} sparse-checkout: | /* !/.repos/ @@ -295,15 +313,19 @@ jobs: # machine. node-pty is N-API, so one binary works across all WSL Node versions. build_wsl_node_pty: name: Build WSL node-pty (linux-x64) - needs: [preflight] - if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' }} + # Same gating as relay_public_config: only the commit SHA is needed, so this + # runs alongside preflight. See the condition comment there. + needs: [check_changes] + if: | + !failure() && !cancelled() && + (github.event_name != 'schedule' || needs.check_changes.outputs.has_changes == 'true') runs-on: blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 15 steps: - name: Checkout uses: actions/checkout@v6 with: - ref: ${{ needs.preflight.outputs.ref }} + ref: ${{ github.sha }} sparse-checkout: | /* !/.repos/ @@ -758,9 +780,8 @@ jobs: - name: Align package versions to release version run: node scripts/update-release-package-versions.ts "${{ needs.preflight.outputs.version }}" - - name: Build web package - run: vp run --filter @t3tools/web build - + # The t3 build task depends on @t3tools/web#build, so the web client is + # built (once) as part of this step. - name: Build CLI package run: vp run --filter t3 build diff --git a/.macroscope/approvability.md b/.macroscope/approvability.md index cfea7fdd57c2..ce4f160ae602 100644 --- a/.macroscope/approvability.md +++ b/.macroscope/approvability.md @@ -1 +1,7 @@ Use Macroscope's default approvability criteria. + +Additionally, any pull request that changes product defaults is not auto-approvable and requires human review. + +Any pull request that adds or broadens a directive that disables or suppresses a lint, +type-checker, LSP, or other static-analysis diagnostic is not auto-approvable and requires +human review. This includes file-level, line-level, and configuration-level overrides. diff --git a/.macroscope/check-run-agents/effect-service-conventions.md b/.macroscope/check-run-agents/effect-service-conventions.md index b76d56d45dbc..57254a1f6eeb 100644 --- a/.macroscope/check-run-agents/effect-service-conventions.md +++ b/.macroscope/check-run-agents/effect-service-conventions.md @@ -82,6 +82,7 @@ Review changed TypeScript and directly affected call sites for the conventions b ## Change discipline - Preserve useful comments, invariants, and specification documentation while moving code. +- Require every new or broadened directive that disables or suppresses a lint, type-checker, LSP, or other static-analysis diagnostic to have an adjacent comment explaining why that diagnostic must be disabled there. The directive itself is not an explanation. Report a missing explanation as a concrete violation. - Do not add large tests solely to prove a mechanical refactor. Update existing tests and imports as needed. - If backend behavior changes, require focused tests. Use test implementations/layers for external services only; do not mock out core business logic. - Do not require `Layer.effect`, universal namespace imports, generic `make`/`layer` names for abstract-port implementations, separate error classes for diagnostic-only fields, or new tests for import-only changes. diff --git a/.macroscope/check-run-agents/ui-consistency.md b/.macroscope/check-run-agents/ui-consistency.md index c2c091b205cf..c2f2c57c1cf2 100644 --- a/.macroscope/check-run-agents/ui-consistency.md +++ b/.macroscope/check-run-agents/ui-consistency.md @@ -70,6 +70,13 @@ The goal is not to minimize CSS or class counts at any cost. The goal is to put - Do not treat a screenshot as proof of keyboard, overflow, scrollbar, responsive, or runtime-theme behavior. Pair visual evidence with source, computed-style, emitted-CSS, or interaction checks as appropriate. - Be alert to shared primitive color indirection. When a primitive routes icon color through a CSS variable, ensure migrated contextual icons retain their intended tone, including pressed and disabled states. +## Environment routing in shared renderers + +- A shared renderer that performs an environment-scoped action — a server RPC such as opening or revealing a file, an environment-gated capability check, or an OS-derived label — must resolve its target environment from explicit scope: the bound thread's `environmentId`, or an `environmentId` prop threaded from the owning surface. Never let it silently fall back to the globally active environment. Multi-environment surfaces (pull request panels, review annotations, cross-environment listings) can render content from environment B while environment A is active; a silent fallback sends B's paths to A's server and presents A's platform wording. +- When a call site cannot supply an explicit environment scope, suppress the environment-scoped actions at that call site rather than guessing. A hidden menu item is correct; an item that targets the wrong server is a concrete finding. +- Capability gating, action dispatch, and user-facing labels must all read from the same environment's server config that the action will execute against. Flag a renderer whose label derives from one environment while its RPC targets another. +- Flag new call sites of shared markdown, chip, or menu renderers that trigger environment actions without passing explicit scope, and flag new environment-action props whose default reintroduces an active-environment fallback. + ## Change discipline - Review the pull request's changed scope and directly affected consumers. Do not turn a focused PR into a demand for unrelated legacy cleanup. diff --git a/AGENTS.md b/AGENTS.md index 784b37cc47b2..492fdb3c29e1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -104,6 +104,7 @@ An empty database is a bad test. Seed your worktree's `.t3` with a copy of real ## Verifying - Smallest proof that the change works. `vp test run ` for the tests you touched, targeted lint and typecheck for the scope you changed. +- Test meaningful logic or observable behavior. Do not render components to static markup to assert props or attributes, or add tests that merely assert callback wiring or mirror the implementation. - **Do not run repo-wide checks.** No `vp check`, no `vp run -r test`, no `vp run -r typecheck` unless I ask. CI owns the full suite. - Backend behavior changes ship with focused tests for that behavior. - The server is event-sourced and its async flows emit typed receipts. Wait on receipts and worker drains, never on sleeps or polling. A test that needs a timeout to pass is wrong. diff --git a/app.json b/app.json deleted file mode 100644 index 306ca48315c1..000000000000 --- a/app.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "expo": {} -} diff --git a/apps/desktop/package.json b/apps/desktop/package.json index a34a55f16acf..83a07cccb660 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/desktop", - "version": "0.0.33", + "version": "0.0.38", "private": true, "type": "module", "main": "dist-electron/main.cjs", @@ -21,7 +21,7 @@ "@t3tools/ssh": "workspace:*", "@t3tools/tailscale": "workspace:*", "effect": "catalog:", - "electron": "41.5.0", + "electron": "43.4.1", "electron-store": "^8.2.0", "electron-updater": "^6.6.2", "playwright-core": "1.60.0", @@ -30,6 +30,7 @@ "devDependencies": { "@effect/vitest": "catalog:", "@types/node": "catalog:", + "acorn": "8.16.0", "cross-env": "^10.1.0", "electron-builder": "26.15.6", "tailwindcss": "^4.0.0", diff --git a/apps/desktop/scripts/verify-preload-bundle.mjs b/apps/desktop/scripts/verify-preload-bundle.mjs new file mode 100644 index 000000000000..34d39fb8e3e4 --- /dev/null +++ b/apps/desktop/scripts/verify-preload-bundle.mjs @@ -0,0 +1,147 @@ +import * as NodeEvents from "node:events"; +import * as NodeFS from "node:fs"; +import * as NodeFSP from "node:fs/promises"; +import * as NodeTimers from "node:timers"; +import * as NodeURL from "node:url"; +import * as NodeVM from "node:vm"; +import { parse } from "acorn"; + +const expectedDesktopBridgeApis = [ + "getClientPlatform", + "getLocalEnvironmentBootstraps", + "pickFolder", +]; +const clerkPasskeysGlobal = "__clerk_internal_electron_passkeys"; +const preloadExecutionTimeoutMs = 1_000; +const desktopPackage = JSON.parse( + NodeFS.readFileSync(new URL("../package.json", import.meta.url), "utf8"), +); +const electronVersion = desktopPackage.dependencies.electron; + +const isSyntaxNode = (value) => + typeof value === "object" && value !== null && "type" in value && typeof value.type === "string"; + +const inspectBundle = (source) => { + const runtimeImports = []; + const visit = (node) => { + if (node.type === "ImportExpression") { + throw new Error("Desktop preload bundle contains a dynamic import() call"); + } + + if (node.type === "CallExpression" && node.callee.type === "Identifier") { + if (node.callee.name === "require") { + const [argument] = node.arguments; + if (node.arguments.length !== 1 || argument?.type !== "Literal") { + throw new Error("Desktop preload bundle contains a dynamic require() call"); + } + if (typeof argument.value !== "string") { + throw new Error("Desktop preload bundle contains a dynamic require() call"); + } + runtimeImports.push(argument.value); + } + } + + for (const child of Object.values(node)) { + if (Array.isArray(child)) { + for (const item of child) { + if (isSyntaxNode(item)) visit(item); + } + } else if (isSyntaxNode(child)) { + visit(child); + } + } + }; + + visit(parse(source, { ecmaVersion: "latest", sourceType: "script" })); + return runtimeImports; +}; + +const createSandboxModules = (exposedGlobals) => { + const ipcRenderer = { + invoke: () => Promise.resolve(undefined), + on: () => undefined, + removeListener: () => undefined, + sendSync: () => undefined, + }; + const electron = { + contextBridge: { + exposeInMainWorld: (name, api) => exposedGlobals.set(name, api), + }, + ipcRenderer, + }; + + return new Map([ + ["electron", electron], + ["electron/common", electron], + ["electron/renderer", electron], + ["events", NodeEvents.default], + ["node:events", NodeEvents.default], + ["timers", NodeTimers.default], + ["node:timers", NodeTimers.default], + ["url", NodeURL.default], + ["node:url", NodeURL.default], + ]); +}; + +const executeBundle = (source, sandboxModules) => { + const sandboxProcess = { + contextIsolated: true, + // oxlint-disable-next-line t3code/no-global-process-runtime -- This standalone CI verifier supplies the preload's host platform without loading Effect. + platform: process.platform, + versions: { electron: electronVersion }, + }; + const requireSandboxModule = (moduleName) => { + if (!sandboxModules.has(moduleName)) { + throw new Error( + `Unsupported sandbox module requested during preload execution: ${moduleName}`, + ); + } + return sandboxModules.get(moduleName); + }; + + NodeVM.runInNewContext( + source, + { + process: sandboxProcess, + require: requireSandboxModule, + }, + { + filename: "desktop-preload.cjs", + timeout: preloadExecutionTimeoutMs, + }, + ); +}; + +export const verifyPreloadBundle = (source) => { + const runtimeImports = inspectBundle(source); + const exposedGlobals = new Map(); + const sandboxModules = createSandboxModules(exposedGlobals); + const unsupportedImports = [...new Set(runtimeImports)] + .filter((moduleName) => !sandboxModules.has(moduleName)) + .toSorted(); + + if (unsupportedImports.length > 0) { + throw new Error( + `Desktop preload bundle contains unsupported sandbox imports: ${unsupportedImports.join(", ")}`, + ); + } + + executeBundle(source, sandboxModules); + + const desktopBridge = exposedGlobals.get("desktopBridge"); + const missingApis = expectedDesktopBridgeApis.filter( + (api) => typeof desktopBridge?.[api] !== "function", + ); + if (!exposedGlobals.has("desktopBridge")) missingApis.unshift("desktopBridge exposure"); + if (!exposedGlobals.has(clerkPasskeysGlobal)) missingApis.push(`${clerkPasskeysGlobal} exposure`); + + if (missingApis.length > 0) { + throw new Error(`Desktop preload bundle is missing executable APIs: ${missingApis.join(", ")}`); + } +}; + +if (process.argv[1] && NodeURL.pathToFileURL(process.argv[1]).href === import.meta.url) { + const preloadUrl = new URL("../dist-electron/preload.cjs", import.meta.url); + const source = await NodeFSP.readFile(preloadUrl, "utf8"); + verifyPreloadBundle(source); +} diff --git a/apps/desktop/scripts/verify-preload-bundle.test.mjs b/apps/desktop/scripts/verify-preload-bundle.test.mjs new file mode 100644 index 000000000000..a80a6d0964c1 --- /dev/null +++ b/apps/desktop/scripts/verify-preload-bundle.test.mjs @@ -0,0 +1,104 @@ +import { assert, describe, it } from "vite-plus/test"; + +import { verifyPreloadBundle } from "./verify-preload-bundle.mjs"; + +const validPreload = ` + const electron = require("electron"); + const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; + electron.contextBridge.exposeInMainWorld("__clerk_internal_electron_passkeys", {}); + electron.contextBridge.exposeInMainWorld("desktopBridge", { + getClientPlatform: () => process.platform, + getLocalEnvironmentBootstraps: () => [], + pickFolder: (options) => electron.ipcRenderer.invoke(PICK_FOLDER_CHANNEL, options), + }); +`; + +describe("desktop preload bundle verifier", () => { + it("rejects required API names that only appear in strings", () => { + assert.throws( + () => + verifyPreloadBundle(` + "desktopBridge getClientPlatform getLocalEnvironmentBootstraps pickFolder"; + "__clerk_internal_electron_passkeys"; + require("electron"); + `), + /missing executable APIs/, + ); + }); + + it("rejects a required API whose exposed value is not callable", () => { + assert.throws( + () => + verifyPreloadBundle( + validPreload.replace( + "getClientPlatform: () => process.platform,", + "getClientPlatform: undefined,", + ), + ), + /missing executable APIs: getClientPlatform/, + ); + }); + + it("accepts a required API exposed through a function alias", () => { + assert.doesNotThrow(() => + verifyPreloadBundle(` + const readClientPlatform = () => process.platform; + ${validPreload.replace( + "getClientPlatform: () => process.platform,", + "getClientPlatform: readClientPlatform,", + )} + `), + ); + }); + + it("rejects dynamic imports with comments before the opening parenthesis", () => { + assert.throws( + () => + verifyPreloadBundle(`${validPreload}\nimport /* @vite-ignore */("unsupported-module");`), + /dynamic import\(\)/, + ); + }); + + it("ignores import-like text in strings", () => { + assert.doesNotThrow(() => + verifyPreloadBundle(`${validPreload}\nconst message = 'import /* comment */("module")';`), + ); + }); + + it("rejects unsupported require calls with comments before the opening parenthesis", () => { + assert.throws( + () => verifyPreloadBundle(`${validPreload}\nrequire /* @__PURE__ */ ("node:fs");`), + /unsupported sandbox imports: node:fs/, + ); + }); + + it("rejects unsupported optional require calls", () => { + assert.throws( + () => verifyPreloadBundle(`${validPreload}\nrequire?.("node:fs");`), + /unsupported sandbox imports: node:fs/, + ); + }); + + it("accepts Electron sandbox module aliases", () => { + assert.doesNotThrow(() => + verifyPreloadBundle(` + ${validPreload} + require("electron/common"); + require("electron/renderer"); + require("node:events"); + require("node:timers"); + require("node:url"); + `), + ); + }); + + it("ignores require-like text in strings and comments", () => { + assert.doesNotThrow(() => + verifyPreloadBundle(` + ${validPreload} + const message = 'require("node:fs")'; + // require("node:path") + `), + ); + }); +}); diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index 4101840530f6..52836582f9e3 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -11,6 +11,7 @@ import * as ElectronDialog from "../electron/ElectronDialog.ts"; import * as ElectronProtocol from "../electron/ElectronProtocol.ts"; import * as ElectronSafeStorage from "../electron/ElectronSafeStorage.ts"; import { installDesktopIpcHandlers } from "../ipc/DesktopIpcHandlers.ts"; +import * as DesktopAppActivation from "./DesktopAppActivation.ts"; import * as DesktopAppIdentity from "./DesktopAppIdentity.ts"; import * as DesktopClerk from "./DesktopClerk.ts"; import * as DesktopApplicationMenu from "../window/DesktopApplicationMenu.ts"; @@ -148,6 +149,7 @@ const bootstrap = Effect.gen(function* () { const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; const wslBackend = yield* DesktopWslBackend.DesktopWslBackend; const desktopWindow = yield* DesktopWindow.DesktopWindow; + const appActivation = yield* DesktopAppActivation.DesktopAppActivation; yield* logBootstrapInfo("bootstrap start"); if (environment.isDevelopment && Option.isNone(environment.configuredBackendPort)) { @@ -210,6 +212,10 @@ const bootstrap = Effect.gen(function* () { } yield* primaryBackend.start; yield* logBootstrapInfo("bootstrap backend start requested"); + yield* appActivation.start.pipe( + Effect.tap(() => logBootstrapInfo("desktop app control socket ready")), + Effect.catch((error) => logStartupError("desktop app control socket unavailable", { error })), + ); // Bring up the WSL backend if the user previously enabled it. The // primary is already starting; reconcile fires off the WSL register // in parallel rather than blocking primary readiness on a possibly diff --git a/apps/desktop/src/app/DesktopAppActivation.test.ts b/apps/desktop/src/app/DesktopAppActivation.test.ts new file mode 100644 index 000000000000..d6ce80322798 --- /dev/null +++ b/apps/desktop/src/app/DesktopAppActivation.test.ts @@ -0,0 +1,140 @@ +// @effect-diagnostics nodeBuiltinImport:off -- This adapter test binds a real local socket or Windows named pipe and verifies its cleanup. +import * as NodeFSP from "node:fs/promises"; +import * as NodeNet from "node:net"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import { + ProjectId, + ThreadId, + type DesktopAppActivationRequest, + type DesktopAppActivationResponse, +} from "@t3tools/contracts"; +import { resolveDesktopAppControlAddress } from "@t3tools/shared/desktopAppControl"; +import { HostProcessPlatform, HostProcessUserId } from "@t3tools/shared/hostProcess"; +import { it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import { afterEach, describe, expect } from "vite-plus/test"; + +import { startDesktopAppControlServer } from "./DesktopAppActivation.ts"; + +const openServers: Array<{ close: () => Promise }> = []; + +afterEach(async () => { + await Promise.all(openServers.splice(0).map((server) => server.close())); +}); + +function makeTarget(stateDir: string, platform: NodeJS.Platform, userId: number | undefined) { + return resolveDesktopAppControlAddress({ + stateDir, + platform, + tempDir: NodeOS.tmpdir(), + userId, + joinPath: NodePath.join, + }); +} + +function request(requestId: string, platform: NodeJS.Platform): DesktopAppActivationRequest { + return { + version: 1, + requestId, + type: "open-workspace", + workspaceRoot: NodePath.join(NodeOS.tmpdir(), "project"), + platform: platform === "win32" ? "win32" : platform === "darwin" ? "darwin" : "linux", + }; +} + +function exchange(address: string, payload: DesktopAppActivationRequest) { + return new Promise((resolve, reject) => { + const socket = NodeNet.createConnection(address); + socket.setEncoding("utf8"); + let buffer = ""; + socket.once("error", reject); + socket.once("connect", () => socket.write(`${JSON.stringify(payload)}\n`)); + socket.on("data", (chunk) => { + buffer += chunk; + const newline = buffer.indexOf("\n"); + if (newline === -1) return; + socket.destroy(); + resolve(JSON.parse(buffer.slice(0, newline)) as DesktopAppActivationResponse); + }); + }); +} + +describe("desktop app control server", () => { + it.effect("roundtrips a request and removes its socket on shutdown", () => + Effect.gen(function* () { + const platform = yield* HostProcessPlatform; + const userId = yield* HostProcessUserId; + yield* Effect.promise(async () => { + const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-app-control-test-")); + const target = makeTarget(NodePath.join(root, "userdata"), platform, userId); + const received: DesktopAppActivationRequest[] = []; + const server = await startDesktopAppControlServer({ + ...target, + userId, + handle: async (input) => { + received.push(input); + return { + version: 1, + requestId: input.requestId, + ok: true, + projectId: ProjectId.make("project-1"), + threadId: ThreadId.make("thread-1"), + }; + }, + cancel: () => undefined, + }); + openServers.push(server); + + const response = await exchange(target.address, request("request-1", platform)); + + expect(received).toHaveLength(1); + expect(response).toMatchObject({ ok: true, requestId: "request-1" }); + await server.close(); + openServers.splice(openServers.indexOf(server), 1); + if (target.directory !== null) { + await expect(NodeFSP.stat(target.address)).rejects.toMatchObject({ code: "ENOENT" }); + } + await NodeFSP.rm(root, { recursive: true, force: true }); + }); + }), + ); + + it.effect("cancels a queued request when the client disconnects", () => + Effect.gen(function* () { + const platform = yield* HostProcessPlatform; + const userId = yield* HostProcessUserId; + yield* Effect.promise(async () => { + const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-app-cancel-test-")); + const target = makeTarget(NodePath.join(root, "userdata"), platform, userId); + let resolveCanceled: (requestId: string) => void = () => undefined; + const canceled = new Promise((resolve) => { + resolveCanceled = resolve; + }); + const server = await startDesktopAppControlServer({ + ...target, + userId, + handle: () => new Promise(() => undefined), + cancel: resolveCanceled, + }); + openServers.push(server); + const socket = NodeNet.createConnection(target.address); + await new Promise((resolve, reject) => { + socket.once("error", reject); + socket.once("connect", () => { + socket.write(`${JSON.stringify(request("request-canceled", platform))}\n`, () => { + socket.destroy(); + resolve(); + }); + }); + }); + + await expect(canceled).resolves.toBe("request-canceled"); + await server.close(); + openServers.splice(openServers.indexOf(server), 1); + await NodeFSP.rm(root, { recursive: true, force: true }); + }); + }), + ); +}); diff --git a/apps/desktop/src/app/DesktopAppActivation.ts b/apps/desktop/src/app/DesktopAppActivation.ts new file mode 100644 index 000000000000..f63fdffedaef --- /dev/null +++ b/apps/desktop/src/app/DesktopAppActivation.ts @@ -0,0 +1,306 @@ +// @effect-diagnostics nodeBuiltinImport:off -- Local socket ownership checks need lstat uid and an atomic stale-socket unlink at the Node adapter boundary. +import * as NodeFSP from "node:fs/promises"; +import * as NodeNet from "node:net"; +import * as NodeOS from "node:os"; + +import { + DESKTOP_APP_ACTIVATION_PROTOCOL_VERSION, + DesktopAppActivationRequest, + type DesktopAppActivationResponse, +} from "@t3tools/contracts"; +import { resolveDesktopAppControlAddress } from "@t3tools/shared/desktopAppControl"; +import { HostProcessUserId } from "@t3tools/shared/hostProcess"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; + +import type * as Electron from "electron"; + +import * as ElectronWindow from "../electron/ElectronWindow.ts"; +import { DESKTOP_APP_ACTIVATION_REQUEST_CHANNEL } from "../ipc/channels.ts"; +import * as DesktopWindow from "../window/DesktopWindow.ts"; +import { DesktopAppActivationBroker } from "./DesktopAppActivationBroker.ts"; +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; +import { makeComponentLogger } from "./DesktopObservability.ts"; + +const MAX_REQUEST_BYTES = 64 * 1024; +const REQUEST_TIMEOUT_MS = 15_000; +const isDesktopAppActivationRequest = Schema.is(DesktopAppActivationRequest); + +export class DesktopAppActivationStartError extends Schema.TaggedErrorClass()( + "DesktopAppActivationStartError", + { + address: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Could not start the desktop app control socket at ${this.address}.`; + } +} + +interface RunningControlServer { + readonly close: () => Promise; +} + +function invalidResponse(requestId: string, message: string): DesktopAppActivationResponse { + return { + version: DESKTOP_APP_ACTIVATION_PROTOCOL_VERSION, + requestId, + ok: false, + code: "invalid-request", + message, + }; +} + +function requestIdFromUnknown(value: unknown): string { + if ( + typeof value === "object" && + value !== null && + "requestId" in value && + typeof value.requestId === "string" && + value.requestId.trim().length > 0 + ) { + return value.requestId; + } + return "invalid-request"; +} + +async function prepareUnixSocket(input: { + readonly address: string; + readonly directory: string; + readonly userId: number | undefined; +}): Promise { + await NodeFSP.mkdir(input.directory, { recursive: true, mode: 0o700 }); + const stat = await NodeFSP.lstat(input.directory); + if (!stat.isDirectory() || stat.isSymbolicLink()) { + throw new Error(`${input.directory} is not a directory.`); + } + if (input.userId !== undefined && stat.uid !== input.userId) { + throw new Error(`${input.directory} is owned by another user.`); + } + await NodeFSP.chmod(input.directory, 0o700); + await NodeFSP.unlink(input.address).catch((error: NodeJS.ErrnoException) => { + if (error.code !== "ENOENT") throw error; + }); +} + +export async function startDesktopAppControlServer(input: { + readonly address: string; + readonly directory: string | null; + readonly userId: number | undefined; + readonly handle: (request: DesktopAppActivationRequest) => Promise; + readonly cancel: (requestId: string) => void; +}): Promise { + if (input.directory !== null) { + await prepareUnixSocket({ + address: input.address, + directory: input.directory, + userId: input.userId, + }); + } + + const sockets = new Set(); + const server = NodeNet.createServer((socket) => { + sockets.add(socket); + socket.setEncoding("utf8"); + let buffer = ""; + let handled = false; + let responseSent = false; + let activeRequestId: string | null = null; + + socket.setTimeout(5_000, () => socket.destroy()); + + const finish = (response: DesktopAppActivationResponse) => { + responseSent = true; + if (!socket.destroyed) socket.end(`${JSON.stringify(response)}\n`); + }; + + socket.on("data", (chunk) => { + if (handled) return; + buffer += chunk; + if (Buffer.byteLength(buffer, "utf8") > MAX_REQUEST_BYTES) { + handled = true; + finish(invalidResponse("invalid-request", "The desktop app request is too large.")); + return; + } + + const newline = buffer.indexOf("\n"); + if (newline === -1) return; + handled = true; + socket.setTimeout(0); + const line = buffer.slice(0, newline); + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + finish(invalidResponse("invalid-request", "The desktop app request is not valid JSON.")); + return; + } + + if (!isDesktopAppActivationRequest(parsed)) { + finish( + invalidResponse(requestIdFromUnknown(parsed), "The desktop app request is invalid."), + ); + return; + } + activeRequestId = parsed.requestId; + void input.handle(parsed).then(finish, () => { + finish( + invalidResponse(parsed.requestId, "T3 Code could not process the desktop app request."), + ); + }); + }); + socket.on("error", () => socket.destroy()); + socket.on("close", () => { + sockets.delete(socket); + if (!responseSent && activeRequestId !== null) input.cancel(activeRequestId); + }); + }); + + await new Promise((resolve, reject) => { + const onError = (error: Error) => { + server.removeListener("listening", onListening); + reject(error); + }; + const onListening = () => { + server.removeListener("error", onError); + resolve(); + }; + server.once("error", onError); + server.once("listening", onListening); + server.listen(input.address); + }); + + try { + if (input.directory !== null) { + await NodeFSP.chmod(input.address, 0o600); + } + } catch (error) { + await new Promise((resolve) => server.close(() => resolve())); + throw error; + } + + let closed = false; + return { + close: async () => { + if (closed) return; + closed = true; + for (const socket of sockets) socket.destroy(); + await new Promise((resolve) => server.close(() => resolve())); + server.removeAllListeners(); + if (input.directory !== null) { + await NodeFSP.unlink(input.address).catch((error: NodeJS.ErrnoException) => { + if (error.code !== "ENOENT") throw error; + }); + } + }, + }; +} + +export class DesktopAppActivation extends Context.Service< + DesktopAppActivation, + { + readonly start: Effect.Effect; + readonly setRendererReady: (ready: boolean) => Effect.Effect; + readonly complete: (response: DesktopAppActivationResponse) => Effect.Effect; + } +>()("@t3tools/desktop/app/DesktopAppActivation") {} + +const { logWarning } = makeComponentLogger("desktop-app-activation"); + +export const make = Effect.gen(function* () { + const desktopEnvironment = yield* DesktopEnvironment.DesktopEnvironment; + const desktopWindow = yield* DesktopWindow.DesktopWindow; + const electronWindow = yield* ElectronWindow.ElectronWindow; + const path = yield* Path.Path; + const userId = yield* HostProcessUserId; + const runPromise = Effect.runPromiseWith(yield* Effect.context()); + const address = resolveDesktopAppControlAddress({ + stateDir: path.resolve(desktopEnvironment.stateDir), + platform: desktopEnvironment.platform, + tempDir: NodeOS.tmpdir(), + userId, + joinPath: path.join, + }); + let registeredWebContents: Electron.WebContents | null = null; + let detachRendererListeners: (() => void) | null = null; + + const broker = new DesktopAppActivationBroker({ + requestTimeoutMs: REQUEST_TIMEOUT_MS, + activate: () => { + void runPromise( + desktopWindow.activate.pipe( + Effect.catchCause((cause) => logWarning("failed to focus the desktop window", { cause })), + ), + ); + }, + }); + + const clearRegisteredRenderer = () => { + detachRendererListeners?.(); + detachRendererListeners = null; + registeredWebContents = null; + broker.clearRenderer(); + }; + + return DesktopAppActivation.of({ + start: Effect.acquireRelease( + Effect.tryPromise({ + try: () => + startDesktopAppControlServer({ + ...address, + userId, + handle: (request) => broker.request(request), + cancel: (requestId) => broker.cancel(requestId), + }), + catch: (cause) => new DesktopAppActivationStartError({ address: address.address, cause }), + }), + (server) => + Effect.promise(() => server.close()).pipe( + Effect.catchCause((cause) => + logWarning("failed to close the desktop app control socket", { cause }), + ), + Effect.ensuring(Effect.sync(() => broker.close())), + ), + ).pipe(Effect.asVoid), + setRendererReady: Effect.fn("DesktopAppActivation.setRendererReady")(function* (ready) { + if (!ready) { + clearRegisteredRenderer(); + return; + } + const main = yield* electronWindow.main; + if (Option.isNone(main)) return; + const webContents = main.value.webContents; + if (webContents.isDestroyed()) return; + + if (registeredWebContents !== webContents) { + clearRegisteredRenderer(); + registeredWebContents = webContents; + const onUnavailable = () => clearRegisteredRenderer(); + const onNavigation = ( + event: Electron.Event, + ) => { + if (event.isMainFrame && !event.isSameDocument) clearRegisteredRenderer(); + }; + webContents.on("did-start-navigation", onNavigation); + webContents.once("destroyed", onUnavailable); + detachRendererListeners = () => { + webContents.removeListener("did-start-navigation", onNavigation); + webContents.removeListener("destroyed", onUnavailable); + }; + } + + broker.registerRenderer((request) => { + webContents.send(DESKTOP_APP_ACTIVATION_REQUEST_CHANNEL, request); + }); + }), + complete: (response) => Effect.sync(() => broker.complete(response)), + }); +}); + +export const layer = Layer.effect(DesktopAppActivation, make); diff --git a/apps/desktop/src/app/DesktopAppActivationBroker.test.ts b/apps/desktop/src/app/DesktopAppActivationBroker.test.ts new file mode 100644 index 000000000000..7a889c2e91d3 --- /dev/null +++ b/apps/desktop/src/app/DesktopAppActivationBroker.test.ts @@ -0,0 +1,130 @@ +import { ProjectId, ThreadId, type DesktopAppActivationRequest } from "@t3tools/contracts"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { DesktopAppActivationBroker } from "./DesktopAppActivationBroker.ts"; + +const request: DesktopAppActivationRequest = { + version: 1, + requestId: "request-1", + type: "open-workspace", + workspaceRoot: "/workspace/project", + platform: "linux", +}; + +describe("DesktopAppActivationBroker", () => { + it("focuses immediately and waits for renderer readiness", async () => { + const activate = vi.fn(); + const send = vi.fn(); + const broker = new DesktopAppActivationBroker({ requestTimeoutMs: 1_000, activate }); + + const response = broker.request(request); + expect(activate).toHaveBeenCalledOnce(); + expect(send).not.toHaveBeenCalled(); + + broker.registerRenderer(send); + expect(send).toHaveBeenCalledWith(request); + broker.complete({ + version: 1, + requestId: request.requestId, + ok: true, + projectId: ProjectId.make("project-1"), + threadId: ThreadId.make("thread-1"), + }); + + await expect(response).resolves.toMatchObject({ ok: true, projectId: "project-1" }); + broker.close(); + }); + + it("fails an in-flight request when the renderer goes away", async () => { + const broker = new DesktopAppActivationBroker({ requestTimeoutMs: 1_000, activate: vi.fn() }); + broker.registerRenderer(vi.fn()); + + const response = broker.request(request); + broker.clearRenderer(); + + await expect(response).resolves.toMatchObject({ + ok: false, + code: "renderer-unavailable", + }); + broker.close(); + }); + + it("queues requests after unsubscribe until a new renderer registers", async () => { + const previousSend = vi.fn(); + const nextSend = vi.fn(); + const broker = new DesktopAppActivationBroker({ requestTimeoutMs: 1_000, activate: vi.fn() }); + broker.registerRenderer(previousSend); + broker.clearRenderer(); + + const response = broker.request(request); + expect(previousSend).not.toHaveBeenCalled(); + expect(nextSend).not.toHaveBeenCalled(); + + broker.registerRenderer(nextSend); + expect(nextSend).toHaveBeenCalledWith(request); + broker.complete({ + version: 1, + requestId: request.requestId, + ok: true, + projectId: ProjectId.make("project-1"), + threadId: ThreadId.make("thread-1"), + }); + + await expect(response).resolves.toMatchObject({ ok: true }); + broker.close(); + }); + + it("removes a queued request when its CLI connection closes", async () => { + const send = vi.fn(); + const broker = new DesktopAppActivationBroker({ requestTimeoutMs: 1_000, activate: vi.fn() }); + + const response = broker.request(request); + broker.cancel(request.requestId); + broker.registerRenderer(send); + + await expect(response).resolves.toMatchObject({ ok: false, code: "renderer-unavailable" }); + expect(send).not.toHaveBeenCalled(); + broker.close(); + }); + + it("never sends a canceled request that was queued behind another request", async () => { + const send = vi.fn(); + const broker = new DesktopAppActivationBroker({ requestTimeoutMs: 1_000, activate: vi.fn() }); + broker.registerRenderer(send); + const secondRequest = { ...request, requestId: "request-2" }; + + const firstResponse = broker.request(request); + const secondResponse = broker.request(secondRequest); + expect(send).toHaveBeenCalledTimes(1); + expect(send).toHaveBeenLastCalledWith(request); + + broker.cancel(secondRequest.requestId); + broker.complete({ + version: 1, + requestId: request.requestId, + ok: true, + projectId: ProjectId.make("project-1"), + threadId: ThreadId.make("thread-1"), + }); + + await expect(firstResponse).resolves.toMatchObject({ ok: true }); + await expect(secondResponse).resolves.toMatchObject({ ok: false }); + expect(send).toHaveBeenCalledTimes(1); + broker.close(); + }); + + it("times out a request without polling", async () => { + vi.useFakeTimers(); + try { + const broker = new DesktopAppActivationBroker({ requestTimeoutMs: 1_000, activate: vi.fn() }); + const response = broker.request(request); + + await vi.advanceTimersByTimeAsync(1_000); + + await expect(response).resolves.toMatchObject({ ok: false, code: "request-timeout" }); + broker.close(); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/apps/desktop/src/app/DesktopAppActivationBroker.ts b/apps/desktop/src/app/DesktopAppActivationBroker.ts new file mode 100644 index 000000000000..221df9ca86dd --- /dev/null +++ b/apps/desktop/src/app/DesktopAppActivationBroker.ts @@ -0,0 +1,146 @@ +// @effect-diagnostics globalTimers:off -- This protocol broker owns cancellable request deadlines outside the Effect runtime. +import { + DESKTOP_APP_ACTIVATION_PROTOCOL_VERSION, + type DesktopAppActivationFailure, + type DesktopAppActivationRequest, + type DesktopAppActivationResponse, +} from "@t3tools/contracts"; + +interface PendingActivation { + readonly request: DesktopAppActivationRequest; + readonly resolve: (response: DesktopAppActivationResponse) => void; + readonly timeout: ReturnType; + dispatched: boolean; +} + +type RendererSender = (request: DesktopAppActivationRequest) => void; + +function failure( + requestId: string, + code: DesktopAppActivationFailure["code"], + message: string, +): DesktopAppActivationFailure { + return { + version: DESKTOP_APP_ACTIVATION_PROTOCOL_VERSION, + requestId, + ok: false, + code, + message, + }; +} + +/** Holds CLI requests until the real desktop renderer is ready to handle them. */ +export class DesktopAppActivationBroker { + readonly #pending = new Map(); + readonly #requestTimeoutMs: number; + readonly #activate: () => void; + #renderer: RendererSender | null = null; + #closed = false; + + constructor(input: { readonly requestTimeoutMs: number; readonly activate: () => void }) { + this.#requestTimeoutMs = input.requestTimeoutMs; + this.#activate = input.activate; + } + + request(request: DesktopAppActivationRequest): Promise { + if (this.#closed) { + return Promise.resolve( + failure(request.requestId, "renderer-unavailable", "T3 Code is shutting down."), + ); + } + if (this.#pending.has(request.requestId)) { + return Promise.resolve( + failure(request.requestId, "invalid-request", "The request id is already in use."), + ); + } + + const response = new Promise((resolve) => { + const timeout = setTimeout(() => { + this.#settle( + failure( + request.requestId, + "request-timeout", + "The desktop app did not finish opening the project in time.", + ), + ); + }, this.#requestTimeoutMs); + this.#pending.set(request.requestId, { + request, + resolve, + timeout, + dispatched: false, + }); + }); + + this.#activate(); + this.#flush(); + return response; + } + + registerRenderer(send: RendererSender): void { + this.#renderer = send; + this.#flush(); + } + + clearRenderer(): void { + this.#renderer = null; + for (const pending of this.#pending.values()) { + if (pending.dispatched) { + this.#settle( + failure( + pending.request.requestId, + "renderer-unavailable", + "The T3 Code window closed before it opened the project.", + ), + ); + } + } + } + + complete(response: DesktopAppActivationResponse): void { + this.#settle(response); + } + + cancel(requestId: string): void { + this.#settle( + failure(requestId, "renderer-unavailable", "The command closed before T3 Code was ready."), + ); + } + + close(): void { + this.#closed = true; + this.#renderer = null; + for (const pending of this.#pending.values()) { + this.#settle( + failure(pending.request.requestId, "renderer-unavailable", "T3 Code is shutting down."), + ); + } + } + + #flush(): void { + const renderer = this.#renderer; + if (renderer === null) return; + if ([...this.#pending.values()].some((pending) => pending.dispatched)) return; + + for (const pending of this.#pending.values()) { + if (pending.dispatched) continue; + try { + pending.dispatched = true; + renderer(pending.request); + } catch { + pending.dispatched = false; + this.#renderer = null; + } + return; + } + } + + #settle(response: DesktopAppActivationResponse): void { + const pending = this.#pending.get(response.requestId); + if (!pending) return; + clearTimeout(pending.timeout); + this.#pending.delete(response.requestId); + pending.resolve(response); + this.#flush(); + } +} diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts index 2bbde73abaa2..accfdf70b3a3 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts @@ -58,14 +58,16 @@ function makeEnvironmentLayer( readonly devServerUrl?: string; readonly platform?: NodeJS.Platform; readonly resourcesPath?: string; + readonly appVersion?: string; + readonly processArch?: NodeJS.Architecture; }, ) { return DesktopEnvironment.layer({ dirname: options?.dirname ?? "/repo/apps/desktop/src", homeDirectory: baseDir, platform: options?.platform ?? "darwin", - processArch: "x64", - appVersion: "1.2.3", + processArch: options?.processArch ?? "x64", + appVersion: options?.appVersion ?? "1.2.3", appPath: options?.appPath ?? "/repo", isPackaged: options?.isPackaged ?? true, resourcesPath: options?.resourcesPath ?? "/missing/resources", @@ -123,7 +125,107 @@ const withHarness = ( ); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)); +interface PackagedWslHarnessContext { + readonly baseDir: string; + readonly archivePath: string; + readonly hashPath: string; + readonly archiveHash: string; + readonly mountedAppRoot: string; + readonly mountedEntryPath: string; +} + +const withPackagedWslHarness = ( + input: { + readonly archiveHash: string; + readonly wsl: ( + context: PackagedWslHarnessContext, + ) => DesktopWslEnvironment.DesktopWslEnvironmentTestStub; + readonly forbidFallback?: string; + readonly cleanupLegacy?: Effect.Effect; + readonly forbidCleanup?: string; + }, + effect: ( + context: PackagedWslHarnessContext, + ) => Effect.Effect< + A, + E, + R | FileSystem.FileSystem | Path.Path | DesktopBackendConfiguration.DesktopBackendConfiguration + >, +) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-backend-config-test-", + }); + const archivePath = path.join(baseDir, "wsl-runtime.tar.gz"); + const hashPath = `${archivePath}.sha256`; + const mountedAppRoot = "/mnt/c/app.asar.unpacked"; + const mountedEntryPath = path.join(baseDir, "app.asar.unpacked/apps/server/dist/bin.mjs"); + yield* fileSystem.makeDirectory(path.dirname(mountedEntryPath), { recursive: true }); + yield* fileSystem.writeFileString(mountedEntryPath, ""); + yield* fileSystem.writeFileString(archivePath, "archive"); + yield* fileSystem.writeFileString(hashPath, `${input.archiveHash}\n`); + + const context = { + baseDir, + archivePath, + hashPath, + archiveHash: input.archiveHash, + mountedAppRoot, + mountedEntryPath, + } satisfies PackagedWslHarnessContext; + const serverTreeLayer = input.forbidFallback + ? Layer.succeed( + DesktopWslServerTree.DesktopWslServerTree, + DesktopWslServerTree.DesktopWslServerTree.of({ + ensure: Effect.die(input.forbidFallback), + cleanupLegacy: input.forbidCleanup + ? Effect.die(input.forbidCleanup) + : (input.cleanupLegacy ?? Effect.void), + }), + ) + : DesktopWslServerTree.layerTest({ + result: { ok: true, root: path.join(baseDir, "app.asar.unpacked") }, + cleanupLegacy: input.cleanupLegacy ?? Effect.void, + }); + + return yield* effect(context).pipe( + Effect.provide( + DesktopBackendConfiguration.layer.pipe( + Layer.provideMerge(serverExposureLayer), + Layer.provideMerge(DesktopAppSettings.layerTest()), + Layer.provideMerge(serverTreeLayer), + Layer.provideMerge( + DesktopWslEnvironment.layerTest({ + isAvailable: true, + distros: [{ name: "Ubuntu", isDefault: true, version: 2 }], + windowsToWslPath: () => Option.some(mountedAppRoot), + getDistroIp: () => Option.some("172.27.0.99"), + ...input.wsl(context), + }), + ), + Layer.provideMerge( + makeEnvironmentLayer(baseDir, { + appPath: baseDir, + platform: "win32", + resourcesPath: baseDir, + }), + ), + ), + ), + ); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)); + describe("DesktopBackendConfiguration", () => { + it("accepts only normalized SHA-256 archive identities", () => { + assert.equal( + DesktopBackendConfiguration.parseWslRuntimeArchiveHash(` ${"A".repeat(64)}\n`), + "a".repeat(64), + ); + assert.isNull(DesktopBackendConfiguration.parseWslRuntimeArchiveHash("abc123")); + }); + it.effect("resolvePrimary produces a stable scoped bootstrap token", () => withHarness( Effect.gen(function* () { @@ -158,10 +260,11 @@ describe("DesktopBackendConfiguration", () => { it.effect("resolvePrimary starts from server.asar without materializing the WSL tree", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; const baseDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-desktop-backend-config-test-", }); - const resourcesPath = `${baseDir}/resources`; + const resourcesPath = path.join(baseDir, "resources"); const config = yield* Effect.gen(function* () { const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; @@ -177,6 +280,7 @@ describe("DesktopBackendConfiguration", () => { DesktopWslServerTree.DesktopWslServerTree, DesktopWslServerTree.DesktopWslServerTree.of({ ensure: Effect.die("Windows primary must not extract the WSL server tree"), + cleanupLegacy: Effect.die("Windows primary must not clean the WSL server tree"), }), ), ), @@ -191,7 +295,10 @@ describe("DesktopBackendConfiguration", () => { ), ); - assert.equal(config.entryPath, `${resourcesPath}/server.asar/apps/server/dist/bin.mjs`); + assert.equal( + config.entryPath, + path.join(resourcesPath, "server.asar/apps/server/dist/bin.mjs"), + ); assert.equal(config.env.ELECTRON_RUN_AS_NODE, "1"); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); @@ -239,7 +346,7 @@ describe("DesktopBackendConfiguration", () => { ], windowsToWslPath: (distro) => { observedDistros.push(distro); - return Option.some("/repo/apps/server/dist/bin.mjs"); + return Option.some("/repo"); }, ensureNodePty: (distro) => { observedDistros.push(distro); @@ -269,6 +376,275 @@ describe("DesktopBackendConfiguration", () => { }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); + it.effect("resolveWsl launches a packaged backend from the WSL-local runtime cache", () => { + const observedArchives: Array<{ + windowsArchivePath: string; + runtimeId: string; + sha256: string; + }> = []; + const observedNodePtyRoots: string[] = []; + let legacyCleanupCount = 0; + const linuxAppRoot = "/home/test/.t3/wsl-runtime/1.2.3-x64"; + + return withPackagedWslHarness( + { + archiveHash: "a".repeat(64), + forbidFallback: "A valid WSL archive must not extract the Windows fallback", + cleanupLegacy: Effect.sync(() => { + legacyCleanupCount += 1; + }), + wsl: () => ({ + prepareRuntime: (_distro, archive) => { + observedArchives.push({ + windowsArchivePath: archive.windowsPath, + runtimeId: archive.runtimeId, + sha256: archive.sha256, + }); + return { ok: true, linuxAppRoot }; + }, + ensureNodePty: (_distro, root) => { + observedNodePtyRoots.push(root); + return { ok: true, nodePath: "/usr/bin/node", resolvedPath: "/usr/bin:/bin" }; + }, + }), + }, + ({ archiveHash, archivePath, baseDir }) => + Effect.gen(function* () { + const path = yield* Path.Path; + const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; + const config = yield* configuration.resolveWsl({ port: 5000, distro: "Ubuntu" }); + + assert.deepEqual(observedArchives, [ + { + windowsArchivePath: archivePath, + runtimeId: `sha256-${archiveHash}`, + sha256: archiveHash, + }, + ]); + assert.deepEqual(observedNodePtyRoots, [linuxAppRoot]); + assert.equal( + config.entryPath, + path.join(baseDir, "server.asar/apps/server/dist/bin.mjs"), + ); + assert.include(config.args, `${linuxAppRoot}/apps/server/dist/bin.mjs`); + assert.equal(config.wslRuntimeId, `sha256-${archiveHash}`); + assert.equal(legacyCleanupCount, 1); + assert.isTrue(Option.isNone(config.preflightFailure)); + }), + ); + }); + + it.effect("resolveWsl changes the cache id when the packaged archive changes", () => { + const firstHash = "a".repeat(64); + const secondHash = "b".repeat(64); + const observedRuntimeIds: string[] = []; + return withPackagedWslHarness( + { + archiveHash: firstHash, + wsl: () => ({ + prepareRuntime: (_distro, archive) => { + observedRuntimeIds.push(archive.runtimeId); + return { ok: true, linuxAppRoot: `/runtime/${archive.runtimeId}` }; + }, + ensureNodePty: () => ({ + ok: true, + nodePath: "/usr/bin/node", + resolvedPath: "/usr/bin:/bin", + }), + }), + }, + ({ hashPath, mountedAppRoot }) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; + const first = yield* configuration.resolveWsl({ port: 5000, distro: "Ubuntu" }); + yield* fileSystem.writeFileString(hashPath, secondHash); + const second = yield* configuration.resolveWsl({ port: 5000, distro: "Ubuntu" }); + yield* fileSystem.writeFileString(hashPath, "not-a-sha256"); + const invalidIdentity = yield* configuration.resolveWsl({ port: 5000, distro: "Ubuntu" }); + + assert.deepEqual(observedRuntimeIds, [`sha256-${firstHash}`, `sha256-${secondHash}`]); + assert.equal(first.wslRuntimeId, observedRuntimeIds[0]); + assert.equal(second.wslRuntimeId, observedRuntimeIds[1]); + assert.isUndefined(invalidIdentity.wslRuntimeId); + assert.include(invalidIdentity.args, `${mountedAppRoot}/apps/server/dist/bin.mjs`); + }), + ); + }); + + it.effect("resolveWsl falls back to the mounted runtime when archive staging fails", () => { + const observedNodePtyRoots: string[] = []; + return withPackagedWslHarness( + { + archiveHash: "b".repeat(64), + wsl: () => ({ + prepareRuntime: () => ({ ok: false, reason: "archive is corrupt" }), + ensureNodePty: (_distro, root) => { + observedNodePtyRoots.push(root); + return { ok: true, nodePath: "/usr/bin/node", resolvedPath: "/usr/bin:/bin" }; + }, + }), + }, + ({ mountedAppRoot, mountedEntryPath }) => + Effect.gen(function* () { + const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; + const config = yield* configuration.resolveWsl({ port: 5000, distro: "Ubuntu" }); + + assert.deepEqual(observedNodePtyRoots, [mountedAppRoot]); + assert.equal(config.entryPath, mountedEntryPath); + assert.include(config.args, `${mountedAppRoot}/apps/server/dist/bin.mjs`); + assert.isUndefined(config.wslRuntimeId); + assert.isTrue(Option.isNone(config.preflightFailure)); + }), + ); + }); + + it.effect("resolveWsl retires a staged runtime that cannot load node-pty", () => { + const archiveHash = "c".repeat(64); + const stagedAppRoot = `/home/test/.t3/wsl-runtime/sha256-${archiveHash}`; + const observedNodePtyRoots: string[] = []; + const invalidatedRuntimeIds: string[] = []; + return withPackagedWslHarness( + { + archiveHash, + wsl: () => ({ + prepareRuntime: () => ({ ok: true, linuxAppRoot: stagedAppRoot }), + invalidateRuntime: (_distro, runtimeId) => + Effect.sync(() => { + invalidatedRuntimeIds.push(runtimeId); + }), + ensureNodePty: (_distro, root) => { + observedNodePtyRoots.push(root); + return root === stagedAppRoot + ? { ok: false, reason: "pty.node could not be loaded", fatal: true } + : { ok: true, nodePath: "/usr/bin/node", resolvedPath: "/usr/bin:/bin" }; + }, + }), + }, + ({ mountedAppRoot, mountedEntryPath }) => + Effect.gen(function* () { + const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; + const config = yield* configuration.resolveWsl({ port: 5000, distro: "Ubuntu" }); + + assert.deepEqual(observedNodePtyRoots, [stagedAppRoot, mountedAppRoot]); + assert.include(config.args, `${mountedAppRoot}/apps/server/dist/bin.mjs`); + assert.equal(config.entryPath, mountedEntryPath); + assert.isUndefined(config.wslRuntimeId); + assert.isTrue(Option.isNone(config.preflightFailure)); + assert.deepEqual(invalidatedRuntimeIds, [`sha256-${archiveHash}`]); + }), + ); + }); + + it.effect("resolveWsl keeps the staged runtime when the mounted tree fails too", () => { + const stagedAppRoot = "/home/test/.t3/wsl-runtime/cache"; + const invalidatedRuntimeIds: string[] = []; + return withPackagedWslHarness( + { + archiveHash: "d".repeat(64), + wsl: () => ({ + prepareRuntime: () => ({ ok: true, linuxAppRoot: stagedAppRoot }), + invalidateRuntime: (_distro, runtimeId) => + Effect.sync(() => { + invalidatedRuntimeIds.push(runtimeId); + }), + ensureNodePty: (_distro, root) => ({ + ok: false, + reason: + root === stagedAppRoot + ? "unsupported CPU architecture or incompatible system libraries" + : "mounted tree is broken in some other way", + fatal: true, + }), + }), + }, + () => + Effect.gen(function* () { + const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; + const config = yield* configuration.resolveWsl({ port: 5000, distro: "Ubuntu" }); + const failure = Option.getOrThrow(config.preflightFailure); + + assert.isTrue(failure.fatal); + assert.include(failure.reason, "unsupported CPU architecture"); + assert.deepEqual(invalidatedRuntimeIds, []); + }), + ); + }); + + it.effect("resolveWsl keeps WSL retryable when the mounted fallback fails transiently", () => { + const stagedAppRoot = "/home/test/.t3/wsl-runtime/cache"; + const invalidatedRuntimeIds: string[] = []; + return withPackagedWslHarness( + { + archiveHash: "f".repeat(64), + wsl: () => ({ + prepareRuntime: () => ({ ok: true, linuxAppRoot: stagedAppRoot }), + invalidateRuntime: (_distro, runtimeId) => + Effect.sync(() => { + invalidatedRuntimeIds.push(runtimeId); + }), + ensureNodePty: (_distro, root) => + root === stagedAppRoot + ? { ok: false, reason: "pty.node could not be loaded", fatal: true } + : { + ok: false, + reason: "WSL backend preflight timed out while probing for Node.js.", + fatal: false, + }, + }), + }, + () => + Effect.gen(function* () { + const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; + const config = yield* configuration.resolveWsl({ port: 5000, distro: "Ubuntu" }); + const failure = Option.getOrThrow(config.preflightFailure); + + assert.isFalse(failure.fatal); + assert.equal(failure.retryLimit, 12); + assert.include(failure.reason, "timed out"); + assert.deepEqual(invalidatedRuntimeIds, []); + }), + ); + }); + + it.effect("resolveWsl retries the staged runtime after a transient probe failure", () => { + const invalidatedRuntimeIds: string[] = []; + return withPackagedWslHarness( + { + archiveHash: "e".repeat(64), + forbidFallback: "A transient probe failure must not extract the fallback", + forbidCleanup: "A transient probe failure must not clean the fallback tree", + wsl: () => ({ + prepareRuntime: () => ({ + ok: true, + linuxAppRoot: "/home/test/.t3/wsl-runtime/cache", + }), + invalidateRuntime: (_distro, runtimeId) => + Effect.sync(() => { + invalidatedRuntimeIds.push(runtimeId); + }), + ensureNodePty: () => ({ + ok: false, + reason: "WSL backend preflight timed out while probing for Node.js.", + fatal: false, + retryLimit: 12, + }), + }), + }, + () => + Effect.gen(function* () { + const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; + const config = yield* configuration.resolveWsl({ port: 5000, distro: "Ubuntu" }); + const failure = Option.getOrThrow(config.preflightFailure); + + assert.isFalse(failure.fatal); + assert.equal(failure.retryLimit, 12); + assert.include(failure.reason, "timed out"); + assert.deepEqual(invalidatedRuntimeIds, []); + }), + ); + }); + it.effect( "resolveWsl preserves inherited PATH with quote-sensitive values as separate args", () => @@ -283,7 +659,8 @@ describe("DesktopBackendConfiguration", () => { yield* fileSystem.writeFileString(entryPath, ""); const nodePath = "/home/test user's/.nvm/versions/node/v22.0.0/bin/node"; - const linuxEntryPath = "/tmp/t3 code's launch/entry file.mjs"; + const linuxAppRoot = "/tmp/t3 code's launch"; + const linuxEntryPath = `${linuxAppRoot}/apps/server/dist/bin.mjs`; const resolvedPath = "/home/test user/bin:/opt/test's tools/bin:/usr/bin:/bin"; const devServerUrl = "http://127.0.0.1:5733/dev%20assets/?label=hello%20world"; const config = yield* Effect.gen(function* () { @@ -299,7 +676,7 @@ describe("DesktopBackendConfiguration", () => { DesktopWslEnvironment.layerTest({ isAvailable: true, distros: [{ name: "Ubuntu", isDefault: true, version: 2 }], - windowsToWslPath: () => Option.some(linuxEntryPath), + windowsToWslPath: () => Option.some(linuxAppRoot), ensureNodePty: () => ({ ok: true, nodePath, resolvedPath }), getDistroIp: () => Option.some("172.27.0.99"), }), @@ -815,13 +1192,14 @@ describe("DesktopBackendConfiguration", () => { it.effect("prefers the external packaged resource monitor over the copy inside the asar", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; const baseDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-desktop-backend-config-test-", }); - const resourcesPath = `${baseDir}/resources`; + const resourcesPath = path.join(baseDir, "resources"); const dirname = `${resourcesPath}/app.asar/apps/desktop/dist-electron`; const embeddedMonitorPath = `${resourcesPath}/app.asar/apps/desktop/prod-resources/resource-monitor/t3-resource-monitor`; - const monitorPath = `${resourcesPath}/resource-monitor/t3-resource-monitor`; + const monitorPath = path.join(resourcesPath, "resource-monitor/t3-resource-monitor"); yield* fileSystem.makeDirectory( `${resourcesPath}/app.asar/apps/desktop/prod-resources/resource-monitor`, { recursive: true }, diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.ts index bcce731a5953..4c43070b5f97 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts @@ -213,6 +213,7 @@ interface SharedBootstrapInput { interface WslPreflightSuccess { readonly _tag: "Ready"; readonly runningDistro: string; + readonly windowsEntryPath: string; readonly linuxEntryPath: string; // Absolute path to the node binary the preflight validated after the shared // remote resolver repaired PATH. The launch must use this exact path so it @@ -222,6 +223,8 @@ interface WslPreflightSuccess { // PATH captured from the same login shell after the shared resolver loaded // version managers. The launch forwards this value directly without a shell. readonly resolvedPath: string; + // Identifies the distro-local runtime cache selected from the packaged archive. + readonly runtimeId?: string; } interface WslPreflightFailure { @@ -236,18 +239,35 @@ interface WslPreflightFailure { } const WSL_TRANSIENT_PREFLIGHT_RETRY_LIMIT = 12; +const WSL_RUNTIME_ARCHIVE_NAME = "wsl-runtime.tar.gz"; +const WSL_RUNTIME_ARCHIVE_HASH_NAME = `${WSL_RUNTIME_ARCHIVE_NAME}.sha256`; +const SHA256_HEX_PATTERN = /^[0-9a-f]{64}$/i; + +export const parseWslRuntimeArchiveHash = (value: string): string | null => { + const trimmed = value.trim(); + return SHA256_HEX_PATTERN.test(trimmed) ? trimmed.toLowerCase() : null; +}; + +type FailedNodePtyResult = Extract< + DesktopWslEnvironment.EnsureWslNodePtyResult, + { readonly ok: false } +>; const runWslPreflight = Effect.fn("desktop.backendConfiguration.wslPreflight")(function* (input: { readonly distro: string | null; - readonly windowsEntryPath: string; - readonly windowsRepoRoot: string; + readonly runtimeArchive: DesktopWslEnvironment.WslRuntimeArchive | null; readonly allowBuild: boolean; }): Effect.fn.Return< WslPreflightSuccess | WslPreflightFailure, never, - DesktopWslEnvironment.DesktopWslEnvironment | FileSystem.FileSystem + | DesktopEnvironment.DesktopEnvironment + | DesktopWslEnvironment.DesktopWslEnvironment + | DesktopWslServerTree.DesktopWslServerTree + | FileSystem.FileSystem > { + const environment = yield* DesktopEnvironment.DesktopEnvironment; const wslEnv = yield* DesktopWslEnvironment.DesktopWslEnvironment; + const wslServerTree = yield* DesktopWslServerTree.DesktopWslServerTree; const fileSystem = yield* FileSystem.FileSystem; const wslAvailable = yield* wslEnv.isAvailable; @@ -289,43 +309,127 @@ const runWslPreflight = Effect.fn("desktop.backendConfiguration.wslPreflight")(f } as const; } - const entryExists = yield* fileSystem - .exists(input.windowsEntryPath) - .pipe(Effect.orElseSucceed(() => false)); - if (!entryExists) { - return { + const nodePtyOptions = { + allowBuild: input.allowBuild, + nodeEngineRange: serverPackageJson.engines.node, + }; + const failedNodePty = (result: FailedNodePtyResult) => + ({ _tag: "Failed", - reason: `missing server entry at ${input.windowsEntryPath}`, - fatal: true, - } as const; + reason: `WSL node-pty unavailable: ${result.reason}`, + fatal: result.fatal, + ...(result.retryLimit === undefined ? {} : { retryLimit: result.retryLimit }), + }) as const; + + // The mounted server tree is the fallback runtime: the Windows-side copy the + // distro reads over /mnt. Slower to launch from, but always installed. + const resolveMountedAppRoot = Effect.gen(function* () { + const serverTree = yield* wslServerTree.ensure; + if (!serverTree.ok) { + return { ok: false, reason: serverTree.reason, fatal: serverTree.fatal } as const; + } + const windowsEntryPath = environment.path.join(serverTree.root, "apps/server/dist/bin.mjs"); + const entryExists = yield* fileSystem + .exists(windowsEntryPath) + .pipe(Effect.orElseSucceed(() => false)); + if (!entryExists) { + return { + ok: false, + reason: `missing server entry at ${windowsEntryPath}`, + fatal: true, + } as const; + } + const mountedAppRoot = yield* wslEnv.windowsToWslPath(runningDistro, serverTree.root); + return Option.isNone(mountedAppRoot) + ? ({ + ok: false, + reason: `wslpath conversion failed for ${serverTree.root}`, + fatal: false, + } as const) + : ({ ok: true, windowsEntryPath, linuxAppRoot: mountedAppRoot.value } as const); + }); + + // Set once a staged runtime has been ruled out by the probe, and carried + // through the mounted attempt: if the mounted tree works the cache is the + // broken part and gets invalidated, and if the mounted tree returns its own + // fatal verdict the cached reason is the more actionable one to report. + // A transient mounted failure is neither — it rules nothing out, so it stays + // retryable and the staged verdict waits for an attempt that can answer. + let stagedFailure: + | { readonly runtimeId: string; readonly nodePty: FailedNodePtyResult } + | undefined; + + if (input.runtimeArchive !== null) { + const runtime = yield* wslEnv.prepareRuntime(runningDistro, input.runtimeArchive); + if (runtime.ok) { + const stagedNodePty = yield* wslEnv.ensureNodePty( + runningDistro, + runtime.linuxAppRoot, + nodePtyOptions, + ); + if (stagedNodePty.ok) { + yield* wslServerTree.cleanupLegacy; + return { + _tag: "Ready", + runningDistro, + windowsEntryPath: environment.backendEntryPath, + linuxEntryPath: `${runtime.linuxAppRoot}/apps/server/dist/bin.mjs`, + nodePath: stagedNodePty.nodePath, + resolvedPath: stagedNodePty.resolvedPath, + runtimeId: input.runtimeArchive.runtimeId, + } as const; + } + // A transport failure says nothing about the staged tree, so it is + // retried against the same cache rather than spending a second probe on + // the mounted tree and risking a needless reinstall. + if (!stagedNodePty.fatal) return failedNodePty(stagedNodePty); + yield* Effect.logWarning( + "The staged WSL runtime could not load node-pty; retrying from the mounted server tree.", + { reason: stagedNodePty.reason }, + ); + stagedFailure = { runtimeId: input.runtimeArchive.runtimeId, nodePty: stagedNodePty }; + } else { + yield* Effect.logWarning( + "Could not stage the WSL runtime; launching from the mounted server tree instead.", + { reason: runtime.reason }, + ); + } } - const linuxEntry = yield* wslEnv.windowsToWslPath(runningDistro, input.windowsEntryPath); - if (Option.isNone(linuxEntry)) { - return { - _tag: "Failed", - reason: `wslpath conversion failed for ${input.windowsEntryPath}`, - fatal: false, - } as const; + const mounted = yield* resolveMountedAppRoot; + if (!mounted.ok) { + return stagedFailure && mounted.fatal + ? failedNodePty(stagedFailure.nodePty) + : ({ _tag: "Failed", reason: mounted.reason, fatal: mounted.fatal } as const); } - const nodePtyResult = yield* wslEnv.ensureNodePty(runningDistro, input.windowsRepoRoot, { - allowBuild: input.allowBuild, - nodeEngineRange: serverPackageJson.engines.node, - }); + const nodePtyResult = yield* wslEnv.ensureNodePty( + runningDistro, + mounted.linuxAppRoot, + nodePtyOptions, + ); if (!nodePtyResult.ok) { - return { - _tag: "Failed", - reason: `WSL node-pty unavailable: ${nodePtyResult.reason}`, - fatal: nodePtyResult.fatal, - ...(nodePtyResult.retryLimit === undefined ? {} : { retryLimit: nodePtyResult.retryLimit }), - } as const; + // Substituting the staged verdict for a transient mounted failure would + // turn a retryable failure into a fatal one, ending the WSL attempt (and, + // in wsl-only mode, persisting Windows) before the slow /mnt path had a + // chance to answer and clear the bad cache. + return failedNodePty( + stagedFailure && nodePtyResult.fatal ? stagedFailure.nodePty : nodePtyResult, + ); + } + + // The mounted tree runs what the cache could not, so the cache is the broken + // copy: revoke its ready marker so the next launch reinstalls it instead of + // reusing a tree that has already been proven unloadable. + if (stagedFailure) { + yield* wslEnv.invalidateRuntime(runningDistro, stagedFailure.runtimeId); } return { _tag: "Ready", runningDistro, - linuxEntryPath: linuxEntry.value, + windowsEntryPath: mounted.windowsEntryPath, + linuxEntryPath: `${mounted.linuxAppRoot}/apps/server/dist/bin.mjs`, nodePath: nodePtyResult.nodePath, resolvedPath: nodePtyResult.resolvedPath, } as const; @@ -430,7 +534,7 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl > { const environment = yield* DesktopEnvironment.DesktopEnvironment; const wslEnvironment = yield* DesktopWslEnvironment.DesktopWslEnvironment; - const wslServerTree = yield* DesktopWslServerTree.DesktopWslServerTree; + const fileSystem = yield* FileSystem.FileSystem; // Bind to 0.0.0.0 inside WSL so the backend is reachable both via // WSL2's automatic localhost forwarding (wslhost: Windows 127.0.0.1 @@ -467,31 +571,54 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl ...buildObservabilityFragment(input.observabilitySettings), }; - // In packaged builds the server tree ships inside resources/server.asar — - // an archive FILE the Windows primary reads through ELECTRON_RUN_AS_NODE - // (asar-aware). The WSL backend launches plain `wsl.exe -- node`, which - // can't read an asar, so materialize (or reuse) the extracted copy of the - // sidecar before preflighting. In dev the server tree is the real checkout - // directory and ensure returns it unchanged. - const serverTree = yield* wslServerTree.ensure; - const wslAppRoot = serverTree.ok ? serverTree.root : environment.serverRoot; - const wslEntryPath = environment.path.join(wslAppRoot, "apps/server/dist/bin.mjs"); - - const preflight = serverTree.ok - ? yield* runWslPreflight({ - distro: input.distro, - windowsEntryPath: wslEntryPath, - windowsRepoRoot: wslAppRoot, - // Packaged builds ship a prebuilt Linux node-pty (built on Linux in CI and - // attached to the Windows artifact — see build-desktop-artifact.ts), so the - // WSL backend never needs a compiler, node-gyp, or network on first launch. - // Compiling from source is a dev-only convenience: a checkout has no shipped - // prebuilt, and developers have the toolchain. In packaged builds we instead - // surface a clear diagnostic if the prebuilt can't load (unsupported - // arch/distro), rather than silently dropping into a fragile runtime build. - allowBuild: !environment.isPackaged, - }) - : ({ _tag: "Failed", reason: serverTree.reason, fatal: serverTree.fatal } as const); + // The archive is the primary packaged WSL path: it installs directly into + // the distro's ext4 filesystem. The server.asar extraction service is only + // consulted lazily if the archive is unavailable or cannot be staged. + const archivePath = environment.path.join(environment.resourcesPath, WSL_RUNTIME_ARCHIVE_NAME); + const archiveHashPath = environment.path.join( + environment.resourcesPath, + WSL_RUNTIME_ARCHIVE_HASH_NAME, + ); + + const hasArchive = environment.isPackaged + ? yield* fileSystem.exists(archivePath).pipe(Effect.orElseSucceed(() => false)) + : false; + const archiveHash = hasArchive + ? yield* fileSystem.readFileString(archiveHashPath).pipe( + Effect.map(parseWslRuntimeArchiveHash), + Effect.orElseSucceed(() => null), + ) + : null; + if (hasArchive && archiveHash === null) { + yield* Effect.logWarning( + "Ignoring the WSL runtime archive because its SHA-256 identity is missing or invalid; launching from the mounted server tree instead.", + { hashPath: archiveHashPath }, + ); + } + + const preflight = yield* runWslPreflight({ + distro: input.distro, + runtimeArchive: + archiveHash === null + ? null + : { + windowsPath: archivePath, + // The verified archive bytes are the cache identity. Release builds + // embed the release version and pnpm install metadata, so the + // archive changes on every update even when application logic does + // not. Later launches of that update still reuse this directory. + runtimeId: `sha256-${archiveHash}`, + sha256: archiveHash, + }, + // Packaged builds ship a prebuilt Linux node-pty (built on Linux in CI and + // attached to the Windows artifact — see build-desktop-artifact.ts), so the + // WSL backend never needs a compiler, node-gyp, or network on first launch. + // Compiling from source is a dev-only convenience: a checkout has no shipped + // prebuilt, and developers have the toolchain. In packaged builds we instead + // surface a clear diagnostic if the prebuilt can't load (unsupported + // arch/distro), rather than silently dropping into a fragile runtime build. + allowBuild: !environment.isPackaged, + }); // Every operation after preflight uses the same concrete distro. In // default-tracking mode this closes the race where the system default @@ -537,7 +664,8 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl const baseConfig = { executablePath: "wsl.exe", - entryPath: wslEntryPath, + entryPath: + preflight._tag === "Ready" ? preflight.windowsEntryPath : environment.backendEntryPath, cwd: environment.backendCwd, env: { ...parentEnvWithoutT3Home, @@ -605,6 +733,7 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl ...devUrlArgs, ], preflightFailure: Option.none(), + ...(preflight.runtimeId === undefined ? {} : { wslRuntimeId: preflight.runtimeId }), } satisfies DesktopBackendManager.DesktopBackendStartConfig; }); diff --git a/apps/desktop/src/backend/DesktopBackendManager.test.ts b/apps/desktop/src/backend/DesktopBackendManager.test.ts index 3efc81ed5b64..e6c9a2c17c18 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.test.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.test.ts @@ -25,6 +25,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import * as DesktopBackendManager from "./DesktopBackendManager.ts"; import * as DesktopObservability from "../app/DesktopObservability.ts"; import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts"; +import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts"; const decodeDesktopBackendBootstrap = Schema.decodeEffect( Schema.fromJsonString(DesktopBackendBootstrap), @@ -132,6 +133,7 @@ interface MakeInstanceInput { readonly desktopTelemetryPublisher?: Partial< DesktopTelemetryPublisher.DesktopTelemetryPublisher["Service"] >; + readonly pruneRuntimes?: (distro: string | null, runtimeId: string) => Effect.Effect; } // Helper that constructs a primary backend instance using the factory @@ -167,6 +169,9 @@ function makeTestInstance(input: MakeInstanceInput) { removeControlSource: () => Effect.void, ...input.desktopTelemetryPublisher, }), + DesktopWslEnvironment.layerTest( + input.pruneRuntimes === undefined ? {} : { pruneRuntimes: input.pruneRuntimes }, + ), ); const instance = DesktopBackendManager.makeBackendInstance({ @@ -647,10 +652,13 @@ describe("DesktopBackendManager", () => { Effect.scoped( Effect.gen(function* () { const requestUrls: Array = []; + const prunedRuntimes: Array<[string | null, string]> = []; const statuses = [503, 200]; let readyCount = 0; const firstRequest = yield* Deferred.make(); - const ready = yield* Deferred.make(); + const backendReady = yield* Deferred.make(); + const processExit = yield* Deferred.make(); + const pruneComplete = yield* Deferred.make(); const exited = yield* Queue.unbounded(); const spawnerLayer = Layer.succeed( @@ -658,7 +666,9 @@ describe("DesktopBackendManager", () => { ChildProcessSpawner.make(() => Effect.succeed( makeProcess({ - exitCode: Deferred.await(ready).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), + exitCode: Deferred.await(processExit).pipe( + Effect.as(ChildProcessSpawner.ExitCode(0)), + ), }), ), ), @@ -666,6 +676,15 @@ describe("DesktopBackendManager", () => { const instance = yield* makeTestInstance({ spawnerLayer, + config: { + ...baseConfig, + runningDistro: "Ubuntu", + wslRuntimeId: "1.2.3-x64", + }, + pruneRuntimes: (distro, runtimeId) => + Effect.sync(() => { + prunedRuntimes.push([distro, runtimeId]); + }).pipe(Effect.andThen(Deferred.succeed(pruneComplete, void 0)), Effect.asVoid), httpClientLayer: httpClientLayer((request) => Effect.gen(function* () { const status = statuses.shift(); @@ -677,7 +696,7 @@ describe("DesktopBackendManager", () => { ), onReady: Effect.sync(() => { readyCount += 1; - }).pipe(Effect.andThen(Deferred.succeed(ready, void 0)), Effect.asVoid), + }).pipe(Effect.andThen(Deferred.succeed(backendReady, void 0)), Effect.asVoid), backendOutputLog: { persistFailure: () => Queue.offer(exited, void 0).pipe(Effect.asVoid), }, @@ -687,12 +706,17 @@ describe("DesktopBackendManager", () => { yield* Deferred.await(firstRequest); assert.equal(readyCount, 0); + assert.deepEqual(prunedRuntimes, []); assert.deepEqual(requestUrls, ["http://127.0.0.1:3773/.well-known/t3/environment"]); yield* TestClock.adjust(Duration.millis(100)); + yield* Deferred.await(backendReady); + yield* Deferred.await(pruneComplete); + yield* Deferred.succeed(processExit, void 0); yield* Queue.take(exited); assert.equal(readyCount, 1); + assert.deepEqual(prunedRuntimes, [["Ubuntu", "1.2.3-x64"]]); assert.deepEqual(requestUrls, [ "http://127.0.0.1:3773/.well-known/t3/environment", "http://127.0.0.1:3773/.well-known/t3/environment", diff --git a/apps/desktop/src/backend/DesktopBackendManager.ts b/apps/desktop/src/backend/DesktopBackendManager.ts index fc1968180901..60bfe780ad62 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.ts @@ -52,6 +52,7 @@ import { waitForHttpReady as waitForHttpReadyShared } from "@t3tools/shared/http import * as DesktopObservability from "../app/DesktopObservability.ts"; import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts"; +import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts"; const INITIAL_RESTART_DELAY = Duration.millis(500); const MAX_RESTART_DELAY = Duration.seconds(10); @@ -99,6 +100,10 @@ export interface DesktopBackendStartConfig extends BackendProcessContext { // Present for a WSL run after the configured/default distro has been // resolved to the concrete distro passed to wsl.exe. readonly runningDistro?: string; + // Present only when this run launched from a staged WSL-local runtime. + // Once HTTP readiness succeeds, the manager uses it to retain this cache + // plus the newest previous cache and prune older versions. + readonly wslRuntimeId?: string; } // A preflight failure records whether it is fatal. Transient failures (WSL @@ -637,6 +642,7 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( | HttpClient.HttpClient | DesktopObservability.DesktopBackendOutputLogFactory | DesktopTelemetryPublisher.DesktopTelemetryPublisher + | DesktopWslEnvironment.DesktopWslEnvironment | Scope.Scope > { const parentScope = yield* Scope.Scope; @@ -644,6 +650,7 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( const backendOutputLogFactory = yield* DesktopObservability.DesktopBackendOutputLogFactory; const backendOutputLog = yield* backendOutputLogFactory.forInstance(spec.id); const desktopTelemetryPublisher = yield* DesktopTelemetryPublisher.DesktopTelemetryPublisher; + const wslEnvironment = yield* DesktopWslEnvironment.DesktopWslEnvironment; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const httpClient = yield* HttpClient.HttpClient; const state = yield* Ref.make(initialState); @@ -939,6 +946,15 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( } yield* spec.onReady?.(config.value.httpBaseUrl) ?? Effect.void; + if ( + config.value.runningDistro !== undefined && + config.value.wslRuntimeId !== undefined + ) { + yield* wslEnvironment.pruneRuntimes( + config.value.runningDistro, + config.value.wslRuntimeId, + ); + } }), onReadinessFailure: Effect.fn("desktop.backendInstance.onReadinessFailure")( function* (error) { diff --git a/apps/desktop/src/backend/DesktopBackendPool.test.ts b/apps/desktop/src/backend/DesktopBackendPool.test.ts index 98bd4065fbee..97d4359e1663 100644 --- a/apps/desktop/src/backend/DesktopBackendPool.test.ts +++ b/apps/desktop/src/backend/DesktopBackendPool.test.ts @@ -14,6 +14,7 @@ import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts"; import * as ElectronDialog from "../electron/ElectronDialog.ts"; import * as DesktopWindow from "../window/DesktopWindow.ts"; +import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts"; import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts"; import * as DesktopBackendPool from "./DesktopBackendPool.ts"; import type { DesktopBackendSnapshot, DesktopBackendStartConfig } from "./DesktopBackendManager.ts"; @@ -79,6 +80,7 @@ function makePoolLayer( resolveWsl: () => Effect.die("unexpected WSL config resolve"), } satisfies DesktopBackendConfiguration.DesktopBackendConfiguration["Service"]), DesktopAppSettings.layerTest(), + DesktopWslEnvironment.layerTest(), ElectronDialog.layer, Layer.succeed(DesktopWindow.DesktopWindow, { createMain: Effect.die("unexpected window create"), diff --git a/apps/desktop/src/backend/DesktopBackendPool.ts b/apps/desktop/src/backend/DesktopBackendPool.ts index 9b85d1bb2430..27e24d55c6bd 100644 --- a/apps/desktop/src/backend/DesktopBackendPool.ts +++ b/apps/desktop/src/backend/DesktopBackendPool.ts @@ -99,6 +99,7 @@ import * as DesktopObservability from "../app/DesktopObservability.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts"; import * as DesktopWindow from "../window/DesktopWindow.ts"; +import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts"; import * as ElectronDialog from "../electron/ElectronDialog.ts"; const { logWarning: logBackendPoolWarning } = @@ -178,7 +179,8 @@ export type BackendInstanceFactoryRequirements = | ChildProcessSpawner.ChildProcessSpawner | HttpClient.HttpClient | DesktopObservability.DesktopBackendOutputLogFactory - | DesktopTelemetryPublisher.DesktopTelemetryPublisher; + | DesktopTelemetryPublisher.DesktopTelemetryPublisher + | DesktopWslEnvironment.DesktopWslEnvironment; interface ActiveRegisteredInstance { readonly _tag: "Active"; diff --git a/apps/desktop/src/electron/ElectronProtocol.test.ts b/apps/desktop/src/electron/ElectronProtocol.test.ts index 2db85dafc4da..a5c03e0b9336 100644 --- a/apps/desktop/src/electron/ElectronProtocol.test.ts +++ b/apps/desktop/src/electron/ElectronProtocol.test.ts @@ -225,6 +225,7 @@ describe("ElectronProtocol", () => { "http:", "https:", ]); + assert.deepEqual(directives["media-src"], ["'self'", "t3code:", "blob:", "http:", "https:"]); assert.deepEqual(directives["font-src"], ["'self'", "t3code:", "data:"]); }); }); diff --git a/apps/desktop/src/electron/ElectronProtocol.ts b/apps/desktop/src/electron/ElectronProtocol.ts index 11459c9ef7a8..fabd598d7ffa 100644 --- a/apps/desktop/src/electron/ElectronProtocol.ts +++ b/apps/desktop/src/electron/ElectronProtocol.ts @@ -87,6 +87,7 @@ export function makeDesktopContentSecurityPolicy(input: DesktopProtocolRegistrat `script-src ${scriptSources.join(" ")}`, `connect-src ${connectSources.join(" ")}`, `img-src 'self' ${input.scheme}: blob: data: http: https:`, + `media-src 'self' ${input.scheme}: blob: http: https:`, "style-src 'self' 'unsafe-inline'", `font-src 'self' ${input.scheme}: data:`, "worker-src 'self' blob:", @@ -117,6 +118,7 @@ export function registerDesktopSchemePrivilegesSync(): void { secure: true, supportFetchAPI: true, corsEnabled: true, + stream: true, }, }, { @@ -126,6 +128,7 @@ export function registerDesktopSchemePrivilegesSync(): void { secure: true, supportFetchAPI: true, corsEnabled: true, + stream: true, }, }, ]); diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index 8e8317db7971..33fa5feacaa7 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -45,12 +45,16 @@ import { showContextMenu, } from "./methods/window.ts"; import * as PreviewIpc from "./methods/preview.ts"; +import * as AppActivationIpc from "./methods/appActivation.ts"; import { getWslState, setWslBackendEnabled, setWslDistro, setWslOnly } from "./methods/wsl.ts"; export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers")(function* () { const ipc = yield* DesktopIpc.DesktopIpc; yield* PreviewIpc.installPreviewEventForwarding(); + yield* ipc.handle(AppActivationIpc.setReady); + yield* ipc.handle(AppActivationIpc.complete); + yield* ipc.handleSync(getAppBranding); yield* ipc.handleSync(getSystemLocale); yield* ipc.handleSync(getWindowFullscreenState); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index c4ef82ec8cb7..90e7add6229e 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -9,6 +9,9 @@ export const MENU_ACTION_CHANNEL = "desktop:menu-action"; export const QUIT_SHORTCUT_CHANNEL = "desktop:quit-shortcut"; export const GET_WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:get-window-fullscreen-state"; export const WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:window-fullscreen-state"; +export const DESKTOP_APP_ACTIVATION_READY_CHANNEL = "desktop:app-activation-ready"; +export const DESKTOP_APP_ACTIVATION_COMPLETE_CHANNEL = "desktop:app-activation-complete"; +export const DESKTOP_APP_ACTIVATION_REQUEST_CHANNEL = "desktop:app-activation-request"; export const UPDATE_STATE_CHANNEL = "desktop:update-state"; export const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state"; export const UPDATE_SET_CHANNEL_CHANNEL = "desktop:update-set-channel"; diff --git a/apps/desktop/src/ipc/methods/appActivation.ts b/apps/desktop/src/ipc/methods/appActivation.ts new file mode 100644 index 000000000000..b5e659b235dc --- /dev/null +++ b/apps/desktop/src/ipc/methods/appActivation.ts @@ -0,0 +1,27 @@ +import { DesktopAppActivationResponse } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import * as DesktopAppActivation from "../../app/DesktopAppActivation.ts"; +import * as IpcChannels from "../channels.ts"; +import * as DesktopIpc from "../DesktopIpc.ts"; + +export const setReady = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.DESKTOP_APP_ACTIVATION_READY_CHANNEL, + payload: Schema.Boolean, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.appActivation.setReady")(function* (ready) { + const activation = yield* DesktopAppActivation.DesktopAppActivation; + yield* activation.setRendererReady(ready); + }), +}); + +export const complete = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.DESKTOP_APP_ACTIVATION_COMPLETE_CHANNEL, + payload: DesktopAppActivationResponse, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.appActivation.complete")(function* (response) { + const activation = yield* DesktopAppActivation.DesktopAppActivation; + yield* activation.complete(response); + }), +}); diff --git a/apps/desktop/src/ipc/methods/preview.test.ts b/apps/desktop/src/ipc/methods/preview.test.ts index 92336cc7362f..e7770dc629dd 100644 --- a/apps/desktop/src/ipc/methods/preview.test.ts +++ b/apps/desktop/src/ipc/methods/preview.test.ts @@ -1,4 +1,5 @@ import { it as effectIt } from "@effect/vitest"; +import { PreviewAutomationStatus } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; @@ -51,4 +52,46 @@ describe("preview IPC methods", () => { }, ), ); + + effectIt.effect("returns automation status for long runtime tab ids", () => + Effect.gen(function* () { + const tabId = + `["environment-1","thread:delegated-task:${"a".repeat(120)}",` + + `"server-epoch-1","preview-1"]`; + const status = { + available: false, + visible: true, + tabId, + url: null, + title: null, + loading: false, + }; + const manager = PreviewManager.PreviewManager.of({ + automationStatus: () => Effect.succeed(status), + } as unknown as PreviewManager.PreviewManager["Service"]); + + expect(tabId.length).toBeGreaterThan(128); + expect( + yield* PreviewIpc.automationStatus + .handler({ tabId }) + .pipe(Effect.provideService(PreviewManager.PreviewManager, manager)), + ).toEqual(status); + }), + ); + + it("keeps the public automation status tab id limit", () => { + const encode = Schema.encodeUnknownSync(PreviewAutomationStatus); + const tabId = "t".repeat(129); + + expect(() => + encode({ + available: false, + visible: true, + tabId, + url: null, + title: null, + loading: false, + }), + ).toThrow(); + }); }); diff --git a/apps/desktop/src/ipc/methods/preview.ts b/apps/desktop/src/ipc/methods/preview.ts index 9850230a03a9..5229d36c31f1 100644 --- a/apps/desktop/src/ipc/methods/preview.ts +++ b/apps/desktop/src/ipc/methods/preview.ts @@ -5,6 +5,7 @@ import { DesktopPreviewAutomationEvaluateInputSchema, DesktopPreviewAutomationPressInputSchema, DesktopPreviewAutomationScrollInputSchema, + DesktopPreviewAutomationStatusSchema, DesktopPreviewAutomationTypeInputSchema, DesktopPreviewAutomationWaitForInputSchema, DesktopPreviewConfigInputSchema, @@ -20,7 +21,6 @@ import { DesktopPreviewWebviewConfigSchema, PreviewAnnotationSubmissionResultSchema, PreviewAutomationSnapshot, - PreviewAutomationStatus, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; @@ -282,7 +282,7 @@ export const copyArtifactToClipboard = DesktopIpc.makeIpcMethod({ export const automationStatus = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PREVIEW_AUTOMATION_STATUS_CHANNEL, payload: DesktopPreviewTabInputSchema, - result: PreviewAutomationStatus, + result: DesktopPreviewAutomationStatusSchema, handler: Effect.fn("desktop.ipc.preview.automationStatus")(function* ({ tabId }) { const manager = yield* PreviewManager.PreviewManager; return yield* manager.automationStatus(tabId); diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 14caeed8a9a1..c826c56e1a70 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -32,6 +32,7 @@ import * as ElectronTheme from "./electron/ElectronTheme.ts"; import * as ElectronUpdater from "./electron/ElectronUpdater.ts"; import * as ElectronWindow from "./electron/ElectronWindow.ts"; import * as DesktopApp from "./app/DesktopApp.ts"; +import * as DesktopAppActivation from "./app/DesktopAppActivation.ts"; import * as DesktopAppIdentity from "./app/DesktopAppIdentity.ts"; import * as DesktopConnectionCatalogStore from "./app/DesktopConnectionCatalogStore.ts"; import * as DesktopClerk from "./app/DesktopClerk.ts"; @@ -157,6 +158,10 @@ const desktopWindowLayer = DesktopWindow.layer.pipe( Layer.provideMerge(desktopPreviewLayer), ); +const desktopAppActivationLayer = DesktopAppActivation.layer.pipe( + Layer.provide(desktopWindowLayer), +); + // Pool layer instantiates the backend factory once for the Windows // primary instance and exposes it via pool.primary. Consumers go through // the pool now; the legacy DesktopBackendManager service is gone. The @@ -184,6 +189,7 @@ const desktopLocalEnvironmentAuthLayer = DesktopLocalEnvironmentAuth.layer.pipe( const desktopApplicationLayer = Layer.mergeAll( DesktopLifecycle.layer, + desktopAppActivationLayer, DesktopApplicationMenu.layer, DesktopLinuxUrlHandler.layer, DesktopShellEnvironment.layer, diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 407c7c3ef498..3e181e2ca698 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -11,6 +11,9 @@ import * as IpcChannels from "./ipc/channels.ts"; exposeClerkBridge({ passkeys: true }); +// oxlint-disable-next-line t3code/no-global-process-runtime -- Electron exposes the client platform in its sandboxed preload process. +const clientPlatform = process.platform; + function unwrapEnsureSshEnvironmentResult(result: unknown) { if ( typeof result === "object" && @@ -35,6 +38,7 @@ contextBridge.exposeInMainWorld("desktopBridge", { } return result as ReturnType; }, + getClientPlatform: () => clientPlatform, getSystemLocale: () => { const result = ipcRenderer.sendSync(IpcChannels.GET_SYSTEM_LOCALE_CHANNEL); return typeof result === "string" ? result : null; @@ -124,9 +128,19 @@ contextBridge.exposeInMainWorld("desktopBridge", { }; }, onQuitShortcut: (listener) => { - const wrappedListener = (_event: Electron.IpcRendererEvent, state: unknown) => { - if (state !== "down" && state !== "up") return; - listener(state); + const wrappedListener = (_event: Electron.IpcRendererEvent, hint: unknown) => { + if (typeof hint !== "object" || hint === null || !("state" in hint)) return; + if (hint.state === "up") { + listener({ state: "up" }); + return; + } + if ( + hint.state === "down" && + "mode" in hint && + (hint.mode === "hold" || hint.mode === "double-click") + ) { + listener({ state: "down", mode: hint.mode }); + } }; ipcRenderer.on(IpcChannels.QUIT_SHORTCUT_CHANNEL, wrappedListener); @@ -164,6 +178,25 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.removeListener(IpcChannels.UPDATE_STATE_CHANNEL, wrappedListener); }; }, + appActivation: { + setReady: (ready) => + ipcRenderer.invoke(IpcChannels.DESKTOP_APP_ACTIVATION_READY_CHANNEL, ready), + complete: (response) => + ipcRenderer.invoke(IpcChannels.DESKTOP_APP_ACTIVATION_COMPLETE_CHANNEL, response), + onRequest: (listener) => { + const wrappedListener = (_event: Electron.IpcRendererEvent, request: unknown) => { + if (typeof request !== "object" || request === null) return; + listener(request as Parameters[0]); + }; + ipcRenderer.on(IpcChannels.DESKTOP_APP_ACTIVATION_REQUEST_CHANNEL, wrappedListener); + return () => { + ipcRenderer.removeListener( + IpcChannels.DESKTOP_APP_ACTIVATION_REQUEST_CHANNEL, + wrappedListener, + ); + }; + }, + }, preview: { createTab: (tabId, defaults) => ipcRenderer.invoke(IpcChannels.PREVIEW_CREATE_TAB_CHANNEL, { diff --git a/apps/desktop/src/preview/BrowserSession.test.ts b/apps/desktop/src/preview/BrowserSession.test.ts index 743fd6a1fcec..50798de916e0 100644 --- a/apps/desktop/src/preview/BrowserSession.test.ts +++ b/apps/desktop/src/preview/BrowserSession.test.ts @@ -184,7 +184,7 @@ describe("BrowserSession", () => { assert.strictEqual(browserSession.clearStorageData.mock.calls.length, 1); assert.deepEqual(browserSession.clearStorageData.mock.calls[0], [ { - storages: ["cookies", "localstorage", "indexdb", "websql", "serviceworkers"], + storages: ["cookies", "localstorage", "indexdb", "serviceworkers"], }, ]); assert.strictEqual(browserSession.clearCache.mock.calls.length, 1); diff --git a/apps/desktop/src/preview/BrowserSession.ts b/apps/desktop/src/preview/BrowserSession.ts index e11d25bbed77..784afe019edf 100644 --- a/apps/desktop/src/preview/BrowserSession.ts +++ b/apps/desktop/src/preview/BrowserSession.ts @@ -168,7 +168,7 @@ export const make = Effect.gen(function* BrowserSessionMake() { Effect.tryPromise({ try: () => browserSession.clearStorageData({ - storages: ["cookies", "localstorage", "indexdb", "websql", "serviceworkers"], + storages: ["cookies", "localstorage", "indexdb", "serviceworkers"], }), catch: (cause) => new BrowserSessionStorageClearError({ diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index 3bf6d63051af..75271d76386a 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -1,4 +1,5 @@ import { it as effectIt } from "@effect/vitest"; +import { DESKTOP_PREVIEW_RECORDING_CAPTURE_TRIGGER } from "@t3tools/contracts"; import type { DesktopPreviewRecordingFrame } from "@t3tools/contracts"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Cause from "effect/Cause"; @@ -36,6 +37,14 @@ describe("fitPictureInPictureContentSize", () => { }); }); +describe("recordingFileExtension", () => { + it("derives the artifact extension from the recorder's actual mime type", () => { + expect(PreviewManager.recordingFileExtension("video/mp4;codecs=avc1.640028")).toBe("mp4"); + expect(PreviewManager.recordingFileExtension("video/webm;codecs=vp9")).toBe("webm"); + expect(PreviewManager.recordingFileExtension("video/x-matroska")).toBe("matroska"); + }); +}); + describe("isPreviewRefreshShortcut", () => { const input = (overrides: Partial = {}) => ({ @@ -58,6 +67,50 @@ describe("isPreviewRefreshShortcut", () => { }); }); +describe("previewWindowOpenAction", () => { + const details = (overrides: { + readonly url?: string; + readonly disposition?: Electron.HandlerDetails["disposition"]; + }) => ({ + url: "https://accounts.google.com/o/oauth2/auth", + disposition: "new-window" as Electron.HandlerDetails["disposition"], + ...overrides, + }); + + it("opens a real window for scripted popups so the opener survives", () => { + // OAuth SDKs read a null `window.open()` as a blocked popup, and they need + // the opener alive to receive the credential back. + expect(PreviewManager.previewWindowOpenAction(details({}))).toBe("popup"); + expect( + PreviewManager.previewWindowOpenAction(details({ url: "http://localhost:5173/auth" })), + ).toBe("popup"); + }); + + it("keeps target=_blank links in the preview tab", () => { + expect(PreviewManager.previewWindowOpenAction(details({ disposition: "foreground-tab" }))).toBe( + "navigate", + ); + expect(PreviewManager.previewWindowOpenAction(details({ disposition: "background-tab" }))).toBe( + "navigate", + ); + }); + + it("does not hand a window to schemes that cannot be hardened", () => { + // A popup skips the `will-attach-webview` hardening, so it only gets a window + // when its preferences can be overridden. Chromium copies the guest's + // preferences for `about:blank` and forbids overriding them. + for (const url of [ + "about:blank", + "javascript:alert(1)", + "file:///etc/passwd", + "vscode://vscode-remote/ssh-remote+box/tmp", + "not a url", + ]) { + expect(PreviewManager.previewWindowOpenAction(details({ url }))).toBe("navigate"); + } + }); +}); + const { browserWindowConstructor, createFromPath, @@ -71,7 +124,7 @@ const { } = vi.hoisted(() => ({ browserWindowConstructor: vi.fn(), createFromPath: vi.fn((): { readonly isEmpty: () => boolean } => ({ isEmpty: () => false })), - fromId: vi.fn((_id?: number) => null), + fromId: vi.fn<(_id?: number) => Electron.WebContents | null>((_id?: number) => null), getFocusedWebContents: vi.fn(() => null), mkdir: vi.fn((_path: string) => undefined), showItemInFolder: vi.fn(), @@ -157,12 +210,53 @@ interface TestCapturedPreviewImage { readonly getSize: () => { readonly width: number; readonly height: number }; } +type TestDisplayMediaHandler = ( + request: { readonly frame: { readonly frameTreeNodeId: number } | null }, + callback: (streams: { video?: unknown }) => void, +) => void; + +interface TestHostWebContents { + readonly id: number; + readonly mainFrame: { readonly frameTreeNodeId: number }; + readonly executeJavaScript: ReturnType; + readonly isDestroyed: () => boolean; + readonly session: { + readonly setDisplayMediaRequestHandler: ReturnType; + }; + readonly displayMediaHandler: () => TestDisplayMediaHandler | undefined; +} + +type TestPreviewWebContents = Electron.WebContents & { + readonly setBackgroundThrottling: ReturnType void>>; +}; + +const makeTestHostWebContents = (): TestHostWebContents => { + let handler: TestDisplayMediaHandler | undefined; + return { + id: 7, + mainFrame: { frameTreeNodeId: 7 }, + executeJavaScript: vi.fn(async () => true), + isDestroyed: () => false, + session: { + setDisplayMediaRequestHandler: vi.fn((next: TestDisplayMediaHandler) => { + handler = next; + }), + }, + displayMediaHandler: () => handler, + }; +}; + const makeTestPreviewWebContents = ( capturePage: () => Promise, id = 42, -) => - ({ + hostWebContents: TestHostWebContents = makeTestHostWebContents(), +) => { + const setBackgroundThrottling = vi.fn<(enabled: boolean) => void>(); + return { id, + mainFrame: { routingId: id }, + hostWebContents, + executeJavaScript: vi.fn(async () => ({ width: 1280, height: 720 })), isDestroyed: () => false, getType: () => "webview", getURL: () => "https://example.com", @@ -171,6 +265,7 @@ const makeTestPreviewWebContents = ( getZoomFactor: () => 1, setZoomFactor: vi.fn(), setAudioMuted: vi.fn(), + setBackgroundThrottling, isCurrentlyAudible: () => false, on: vi.fn(), off: vi.fn(), @@ -186,7 +281,45 @@ const makeTestPreviewWebContents = ( off: vi.fn(), }, capturePage, - }) as never; + } as unknown as TestPreviewWebContents; +}; + +/** Two ready tabs (41, 42) sharing one window, so they contend for the single display-media slot. */ +const setupRecordingRaceTabs = (manager: PreviewManager.PreviewManager["Service"]) => + Effect.gen(function* () { + const capturePage = vi.fn(async () => ({ + toJPEG: () => Buffer.from("unused-recording-frame"), + getSize: () => ({ width: 1280, height: 720 }), + })); + const host = makeTestHostWebContents(); + const destroyedIds = new Set(); + const makeWebContents = (id: number) => + Object.assign(makeTestPreviewWebContents(capturePage, id, host), { + executeJavaScript: vi.fn(async () => ({ width: 1280, height: 720 })), + isDestroyed: () => destroyedIds.has(id), + }); + const webContentsById = new Map([ + [41, makeWebContents(41)], + [42, makeWebContents(42)], + ]); + fromId.mockImplementation((id) => + id === undefined ? null : (webContentsById.get(id) ?? null), + ); + yield* manager.createTab("tab_race_a"); + yield* manager.createTab("tab_race_b"); + yield* manager.registerWebview("tab_race_a", 41); + yield* manager.registerWebview("tab_race_b", 42); + const grants: Array<{ video?: unknown }> = []; + return { + host, + grants, + destroy: (id: number) => destroyedIds.add(id), + takeGrant: (frame = host.mainFrame) => + host.displayMediaHandler()?.({ frame }, (value) => { + grants.push(value); + }), + }; + }); const TEST_FAVICON = "data:image/png;base64,cG5n"; @@ -288,8 +421,18 @@ const settle = function* (until: () => boolean) { const makeTestPictureInPictureWindow = (loadURL: () => Promise = async () => undefined) => { const listeners = new Map void>(); + const webContentsListeners = new Map void>(); const send = vi.fn(); let destroyed = false; + const webContents = { + on: vi.fn((event: string, listener: () => void) => { + webContentsListeners.set(event, listener); + }), + off: vi.fn((event: string) => { + webContentsListeners.delete(event); + }), + send, + }; const pictureInPictureWindow = { isDestroyed: vi.fn(() => destroyed), once: vi.fn((event: string, listener: () => void) => { @@ -309,11 +452,12 @@ const makeTestPictureInPictureWindow = (loadURL: () => Promise = async () destroyed = true; listeners.get("closed")?.(); }), - webContents: { - send, + get webContents() { + if (destroyed) throw new Error("Picture-in-picture window is closed."); + return webContents; }, }; - return { pictureInPictureWindow, send }; + return { pictureInPictureWindow, send, webContentsListeners }; }; describe("PreviewManager", () => { @@ -1611,7 +1755,9 @@ describe("PreviewManager", () => { const recreated = yield* Fiber.join(recreateFiber); const registrationExit = yield* Fiber.await(registrationFiber); - for (const exit of [registrationExit, recordingExit]) { + for (const exit of [registrationExit, recordingExit] as ReadonlyArray< + Exit.Exit + >) { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isSuccess(exit)) continue; expect(Option.getOrThrow(Cause.findErrorOption(exit.cause))).toMatchObject({ @@ -1797,7 +1943,7 @@ describe("PreviewManager", () => { ), ); - effectIt.effect("keeps window unthrottled until the final frame capture stops", () => + effectIt.effect("keeps every recorded guest unthrottled until its frame capture stops", () => withManager((manager) => Effect.gen(function* () { const setBackgroundThrottling = vi.fn(); @@ -1805,9 +1951,12 @@ describe("PreviewManager", () => { toJPEG: () => Buffer.from("recording-frame"), getSize: () => ({ width: 1280, height: 720 }), })); + const host = makeTestHostWebContents(); + const firstWebContents = makeTestPreviewWebContents(capturePage, 41, host); + const secondWebContents = makeTestPreviewWebContents(capturePage, 42, host); const webContentsById = new Map([ - [41, makeTestPreviewWebContents(capturePage, 41)], - [42, makeTestPreviewWebContents(capturePage, 42)], + [41, firstWebContents], + [42, secondWebContents], ]); fromId.mockImplementation((id) => id === undefined ? null : (webContentsById.get(id) ?? null), @@ -1824,14 +1973,21 @@ describe("PreviewManager", () => { } as never); yield* manager.startRecording("tab_capture_throttling_1"); + // The first renderer takes its grant, freeing the arm slot for the second tab. + host.displayMediaHandler()?.({ frame: host.mainFrame }, () => {}); yield* manager.startRecording("tab_capture_throttling_2"); expect(setBackgroundThrottling.mock.calls).toEqual([[false]]); + expect(firstWebContents.setBackgroundThrottling.mock.calls).toEqual([[false]]); + expect(secondWebContents.setBackgroundThrottling.mock.calls).toEqual([[false]]); yield* manager.stopRecording("tab_capture_throttling_1"); expect(setBackgroundThrottling.mock.calls).toEqual([[false]]); + expect(firstWebContents.setBackgroundThrottling.mock.calls).toEqual([[false], [true]]); + expect(secondWebContents.setBackgroundThrottling.mock.calls).toEqual([[false]]); yield* manager.stopRecording("tab_capture_throttling_2"); expect(setBackgroundThrottling.mock.calls).toEqual([[false], [true]]); + expect(secondWebContents.setBackgroundThrottling.mock.calls).toEqual([[false], [true]]); }), ), ); @@ -1885,6 +2041,48 @@ describe("PreviewManager", () => { ), ); + effectIt.effect("rolls back window throttling when a recorded guest cannot be unthrottled", () => + withManager((manager) => + Effect.gen(function* () { + const setWindowBackgroundThrottling = vi.fn(); + const capturePage = vi.fn(async () => ({ + toJPEG: () => Buffer.from("recording-frame"), + getSize: () => ({ width: 1280, height: 720 }), + })); + const wc = makeTestPreviewWebContents(capturePage); + fromId.mockReturnValue(wc); + + yield* manager.createTab("tab_guest_throttling_failure"); + yield* manager.registerWebview("tab_guest_throttling_failure", 42); + yield* manager.setMainWindow({ + isDestroyed: () => false, + once: vi.fn(), + webContents: { setBackgroundThrottling: setWindowBackgroundThrottling }, + } as never); + + wc.setBackgroundThrottling.mockImplementationOnce(() => { + throw new Error("guest throttling update failed"); + }); + const failedStart = yield* Effect.exit( + manager.startRecording("tab_guest_throttling_failure"), + ); + expect(Exit.isFailure(failedStart)).toBe(true); + expect(setWindowBackgroundThrottling.mock.calls).toEqual([[false], [true]]); + expect(wc.setBackgroundThrottling.mock.calls).toEqual([[false]]); + + yield* manager.startRecording("tab_guest_throttling_failure"); + yield* manager.stopRecording("tab_guest_throttling_failure"); + expect(setWindowBackgroundThrottling.mock.calls).toEqual([ + [false], + [true], + [false], + [true], + ]); + expect(wc.setBackgroundThrottling.mock.calls).toEqual([[false], [false], [true]]); + }), + ), + ); + effectIt.effect("does not publish a replacement window when capture reconciliation fails", () => withManager((manager) => Effect.gen(function* () { @@ -1963,9 +2161,10 @@ describe("PreviewManager", () => { toJPEG: () => Buffer.from("recording-frame"), getSize: () => ({ width: 1280, height: 720 }), })); + const host = makeTestHostWebContents(); const webContentsById = new Map([ - [42, makeTestPreviewWebContents(capturePage, 42)], - [43, makeTestPreviewWebContents(capturePage, 43)], + [42, makeTestPreviewWebContents(capturePage, 42, host)], + [43, makeTestPreviewWebContents(capturePage, 43, host)], ]); fromId.mockImplementation((id) => id === undefined ? null : (webContentsById.get(id) ?? null), @@ -1997,6 +2196,10 @@ describe("PreviewManager", () => { yield* Effect.yieldNow; yield* Effect.yieldNow; + const grants: Array<{ video?: unknown }> = []; + host.displayMediaHandler()?.({ frame: host.mainFrame }, (value) => grants.push(value)); + expect(grants).toEqual([{}]); + yield* manager.setMainWindow({ isDestroyed: () => false, once: vi.fn(), @@ -2007,7 +2210,60 @@ describe("PreviewManager", () => { ), ); - effectIt.effect("captures hidden preview recordings independently for concurrent tabs", () => + effectIt.effect("does not arm recording after the main window closes during warmup", () => + withManager((manager) => + Effect.gen(function* () { + let closeMainWindow: (() => void) | undefined; + let finishWarmup!: (image: TestCapturedPreviewImage) => void; + let markWarmupStarted!: () => void; + const warmupStarted = new Promise((resolve) => { + markWarmupStarted = resolve; + }); + const capturedImage = { + toJPEG: () => Buffer.from("recording-frame"), + getSize: () => ({ width: 1280, height: 720 }), + }; + const capturePage = vi.fn( + () => + new Promise((resolve) => { + markWarmupStarted(); + finishWarmup = resolve; + }), + ); + const host = makeTestHostWebContents(); + fromId.mockReturnValue(makeTestPreviewWebContents(capturePage, 42, host)); + + yield* manager.createTab("tab_window_close_warmup"); + yield* manager.registerWebview("tab_window_close_warmup", 42); + yield* manager.setMainWindow({ + isDestroyed: () => false, + once: vi.fn((event: string, listener: () => void) => { + if (event === "closed") closeMainWindow = listener; + }), + webContents: { setBackgroundThrottling: vi.fn() }, + } as never); + + const start = yield* manager + .startRecording("tab_window_close_warmup") + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Effect.promise(() => warmupStarted); + closeMainWindow?.(); + finishWarmup(capturedImage); + + const exit = yield* Fiber.await(start); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(Option.getOrThrow(Cause.findErrorOption(exit.cause))).toMatchObject({ + _tag: "PreviewMainWindowClosedError", + tabId: "tab_window_close_warmup", + }); + } + expect(host.session.setDisplayMediaRequestHandler).not.toHaveBeenCalled(); + }), + ), + ); + + effectIt.effect("grants each concurrent preview recording its own tab frame", () => withManager((manager) => Effect.gen(function* () { const firstJpeg = Buffer.from("first-recording-frame"); @@ -2022,6 +2278,8 @@ describe("PreviewManager", () => { })); const firstSendCommand = vi.fn(async () => undefined); const secondSendCommand = vi.fn(async () => undefined); + // Both webviews live in the same window, so they share one display-media handler. + const host = makeTestHostWebContents(); const makeWebContents = ( id: number, capturePage: typeof firstCapturePage, @@ -2029,6 +2287,11 @@ describe("PreviewManager", () => { ) => ({ id, + mainFrame: { routingId: id }, + hostWebContents: host, + executeJavaScript: vi.fn(async () => + id === 41 ? { width: 800, height: 600 } : { width: 390, height: 844 }, + ), isDestroyed: () => false, getType: () => "webview", getURL: () => `https://example.com/${id}`, @@ -2037,6 +2300,7 @@ describe("PreviewManager", () => { getZoomFactor: () => 1, setZoomFactor: vi.fn(), setAudioMuted: vi.fn(), + setBackgroundThrottling: vi.fn(), isCurrentlyAudible: () => false, on: vi.fn(), off: vi.fn(), @@ -2060,41 +2324,25 @@ describe("PreviewManager", () => { fromId.mockImplementation((id) => id === undefined ? null : (webContentsById.get(id) ?? null), ); - const frames: DesktopPreviewRecordingFrame[] = []; - - yield* manager.subscribeRecordingFrames((frame) => - Effect.sync(() => { - frames.push(frame); - }), - ); yield* manager.createTab("tab_1"); yield* manager.createTab("tab_2"); yield* manager.registerWebview("tab_1", 41); yield* manager.registerWebview("tab_2", 42); - yield* Effect.all([manager.startRecording("tab_1"), manager.startRecording("tab_2")], { - concurrency: 2, - discard: true, - }); + + const grants: Array<{ video?: unknown }> = []; + const takeGrant = () => + host.displayMediaHandler()?.({ frame: host.mainFrame }, (value) => { + grants.push(value); + }); + + yield* manager.startRecording("tab_1"); + takeGrant(); + yield* manager.startRecording("tab_2"); + takeGrant(); + expect(grants).toEqual([{ video: { routingId: 41 } }, { video: { routingId: 42 } }]); expect(firstCapturePage).toHaveBeenCalledOnce(); expect(secondCapturePage).toHaveBeenCalledOnce(); - expect(frames).toHaveLength(2); - expect(frames).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - tabId: "tab_1", - data: firstJpeg.toString("base64"), - width: 800, - height: 600, - }), - expect.objectContaining({ - tabId: "tab_2", - data: secondJpeg.toString("base64"), - width: 390, - height: 844, - }), - ]), - ); expect(firstSendCommand).not.toHaveBeenCalledWith( "Page.startScreencast", expect.anything(), @@ -2112,203 +2360,219 @@ describe("PreviewManager", () => { ), ); - effectIt.effect("drops a captured frame when the tab webview changes during capture", () => + effectIt.effect("requests display media with a fresh renderer gesture", () => withManager((manager) => Effect.gen(function* () { - const staleImage: TestCapturedPreviewImage = { - toJPEG: vi.fn(() => Buffer.from("stale-recording-frame")), - getSize: vi.fn(() => ({ width: 1280, height: 720 })), - }; - let markCaptureStarted!: () => void; - const captureStarted = new Promise((resolve) => { - markCaptureStarted = resolve; - }); - let resolveCapture: ((image: TestCapturedPreviewImage) => void) | undefined; - const staleCapturePage = vi.fn(() => { - markCaptureStarted(); - return new Promise((resolve) => { - resolveCapture = resolve; - }); + const { host, takeGrant } = yield* setupRecordingRaceTabs(manager); + + yield* manager.startRecording("tab_race_a"); + + expect(host.executeJavaScript).toHaveBeenCalledWith( + expect.stringContaining(DESKTOP_PREVIEW_RECORDING_CAPTURE_TRIGGER), + true, + ); + expect(host.executeJavaScript).toHaveBeenCalledWith( + expect.stringContaining("tab_race_a"), + true, + ); + takeGrant(); + yield* manager.stopRecording("tab_race_a"); + }), + ), + ); + + // Runs on the real clock: an earlier queueing design only settled under TestClock and stalled the + // losing start forever in the desktop app. + effectIt.live("settles both starts when two tabs race for the capture stream", () => + withManager((manager) => + Effect.gen(function* () { + const { host, grants, takeGrant } = yield* setupRecordingRaceTabs(manager); + + const exits = yield* Effect.all( + [ + Effect.exit(manager.startRecording("tab_race_a")), + Effect.exit(manager.startRecording("tab_race_b")), + ], + { concurrency: 2 }, + ); + + const [exitA, exitB] = exits; + // Exactly one start owns the stream; the other fails fast instead of hanging. + expect(exits.filter(Exit.isSuccess)).toHaveLength(1); + const loserExit = Exit.isSuccess(exitA) ? exitB : exitA; + if (Exit.isSuccess(loserExit)) return; + expect(Option.getOrThrow(Cause.findErrorOption(loserExit.cause))).toMatchObject({ + _tag: "PreviewRecordingArmConflictError", }); - const replacementCapturePage = vi.fn(async () => ({ - toJPEG: () => Buffer.from("replacement-recording-frame"), - getSize: () => ({ width: 1280, height: 720 }), - })); - const initialWebContents = makeTestPreviewWebContents(staleCapturePage, 42); - const replacementWebContents = makeTestPreviewWebContents(replacementCapturePage, 43); - fromId.mockImplementation((webContentsId?: number) => { - if (webContentsId === 42) return initialWebContents; - if (webContentsId === 43) return replacementWebContents; - return null; + + // The single grant goes to the tab that actually won the slot, never the other one. + takeGrant(); + expect(grants).toEqual([{ video: { routingId: Exit.isSuccess(exitA) ? 41 : 42 } }]); + expect(host.session.setDisplayMediaRequestHandler).toHaveBeenCalledOnce(); + }), + ), + ); + + effectIt.effect("releases an armed slot that the renderer never redeemed", () => + withManager((manager) => + Effect.gen(function* () { + const { grants, takeGrant } = yield* setupRecordingRaceTabs(manager); + + yield* manager.startRecording("tab_race_a"); + const blocked = yield* Effect.exit(manager.startRecording("tab_race_b")); + if (Exit.isSuccess(blocked)) throw new Error("expected the second tab to be refused"); + expect(Option.getOrThrow(Cause.findErrorOption(blocked.cause))).toMatchObject({ + _tag: "PreviewRecordingArmConflictError", + tabId: "tab_race_b", + armedTabId: "tab_race_a", }); - const frames: DesktopPreviewRecordingFrame[] = []; - yield* manager.subscribeRecordingFrames((frame) => - Effect.sync(() => { - frames.push(frame); - }), - ); - yield* manager.createTab("tab_capture_replaced"); - yield* manager.registerWebview("tab_capture_replaced", 42); - const recordingFiber = yield* manager - .startRecording("tab_capture_replaced") - .pipe(Effect.forkChild({ startImmediately: true })); - yield* Effect.promise(() => captureStarted); + // Nothing ever captured the armed tab, so the slot goes stale and stops blocking starts. + yield* TestClock.adjust(10_000); + yield* manager.startRecording("tab_race_b"); + takeGrant(); + expect(grants).toEqual([{ video: { routingId: 42 } }]); - yield* manager.registerWebview("tab_capture_replaced", 43); - resolveCapture?.(staleImage); - yield* Fiber.join(recordingFiber); + yield* manager.stopRecording("tab_race_a"); + yield* manager.stopRecording("tab_race_b"); + }), + ), + ); - expect(staleImage.getSize).not.toHaveBeenCalled(); - expect(staleImage.toJPEG).not.toHaveBeenCalled(); - expect(frames).toHaveLength(0); - expect(replacementCapturePage).not.toHaveBeenCalled(); + effectIt.effect("denies a display-media request that arrives after the arm went stale", () => + withManager((manager) => + Effect.gen(function* () { + const { grants, takeGrant } = yield* setupRecordingRaceTabs(manager); + + yield* manager.startRecording("tab_race_a"); + yield* TestClock.adjust(10_000); + // The handler cannot read a clock, so the expiry fiber must have dropped the frame. + takeGrant(); + expect(grants).toEqual([{}]); - yield* manager.stopRecording("tab_capture_replaced"); + yield* manager.stopRecording("tab_race_a"); }), ), ); - effectIt.effect("keeps an in-flight frame when a capture consumer is added", () => + effectIt.effect("only lets the host frame that armed a recording claim its stream", () => withManager((manager) => Effect.gen(function* () { - const image: TestCapturedPreviewImage = { - toJPEG: vi.fn(() => Buffer.from("shared-in-flight-frame")), - getSize: vi.fn(() => ({ width: 1280, height: 720 })), - }; - let markCaptureStarted!: () => void; - const captureStarted = new Promise((resolve) => { - markCaptureStarted = resolve; - }); - let resolveCapture: ((captured: TestCapturedPreviewImage) => void) | undefined; - const capturePage = vi.fn(() => { - markCaptureStarted(); - return new Promise((resolve) => { - resolveCapture = resolve; - }); + const { grants, takeGrant } = yield* setupRecordingRaceTabs(manager); + + yield* manager.startRecording("tab_race_a"); + takeGrant({ frameTreeNodeId: 999 }); + takeGrant(); + + expect(grants).toEqual([{}, { video: { routingId: 41 } }]); + yield* manager.stopRecording("tab_race_a"); + }), + ), + ); + + effectIt.effect("reclaims the arm slot from a destroyed webContents", () => + withManager((manager) => + Effect.gen(function* () { + const { grants, takeGrant, destroy } = yield* setupRecordingRaceTabs(manager); + + yield* manager.startRecording("tab_race_a"); + destroy(41); + yield* manager.startRecording("tab_race_b"); + takeGrant(); + expect(grants).toEqual([{ video: { routingId: 42 } }]); + + yield* manager.stopRecording("tab_race_b"); + }), + ), + ); + + effectIt.effect("continues native recording when the source warmup fails", () => + withManager((manager) => + Effect.gen(function* () { + const capturePage = vi.fn(async () => { + throw new Error("source is not ready"); }); - fromId.mockReturnValue(makeTestPreviewWebContents(capturePage)); - const { pictureInPictureWindow, send } = makeTestPictureInPictureWindow(); - browserWindowConstructor.mockImplementation(function () { - return pictureInPictureWindow; + const host = makeTestHostWebContents(); + const webContents = Object.assign(makeTestPreviewWebContents(capturePage, 42, host), { + executeJavaScript: vi.fn(async () => ({ width: 1280, height: 720 })), }); - const recordingFrames: DesktopPreviewRecordingFrame[] = []; - yield* manager.subscribeRecordingFrames((frame) => - Effect.sync(() => { - recordingFrames.push(frame); - }), - ); + fromId.mockReturnValue(webContents); - yield* manager.createTab("tab_capture_consumer_added"); - yield* manager.registerWebview("tab_capture_consumer_added", 42); - const recordingFiber = yield* manager - .startRecording("tab_capture_consumer_added") - .pipe(Effect.forkChild({ startImmediately: true })); - yield* Effect.promise(() => captureStarted); + yield* manager.createTab("tab_recording_warmup_failure"); + yield* manager.registerWebview("tab_recording_warmup_failure", 42); - yield* manager.openPictureInPicture("tab_capture_consumer_added"); - resolveCapture?.(image); - yield* Fiber.join(recordingFiber); + yield* manager.startRecording("tab_recording_warmup_failure"); + expect(capturePage).toHaveBeenCalledTimes(2); - expect(recordingFrames).toHaveLength(1); - expect(send).toHaveBeenCalledWith( - "desktop:preview-pip-frame", - expect.objectContaining({ - tabId: "tab_capture_consumer_added", - data: Buffer.from("shared-in-flight-frame").toString("base64"), - }), - ); + // The armed tab answers exactly one display-media request, then further requests are denied. + const handler = host.displayMediaHandler(); + const streams: Array<{ video?: unknown }> = []; + handler?.({ frame: host.mainFrame }, (value) => streams.push(value)); + handler?.({ frame: host.mainFrame }, (value) => streams.push(value)); + expect(streams).toEqual([{ video: { routingId: 42 } }, {}]); - yield* manager.stopRecording("tab_capture_consumer_added"); - yield* manager.closePictureInPicture("tab_capture_consumer_added"); + yield* manager.stopRecording("tab_recording_warmup_failure"); }), ), ); - effectIt.effect("emits debugger screencast frames only while recording is active", () => + effectIt.effect("serializes recording source acquisition with webview replacement", () => withManager((manager) => Effect.gen(function* () { - let debuggerMessage: - | ((event: unknown, method: string, params: Record) => void) - | undefined; - const capturePage = vi.fn(async () => ({ - toJPEG: () => Buffer.from("scheduled-recording-frame"), + const capturedImage = { + toJPEG: () => Buffer.from("unused-recording-frame"), getSize: () => ({ width: 1280, height: 720 }), - })); - const sendCommand = vi.fn(async (method: string) => - method === "Runtime.evaluate" ? { result: { value: null } } : undefined, - ); - fromId.mockReturnValue({ - id: 42, - isDestroyed: () => false, - getType: () => "webview", - getURL: () => "https://example.com", - getTitle: () => "Example", - isLoading: () => false, - isDevToolsOpened: () => false, - getZoomFactor: () => 1, - setZoomFactor: vi.fn(), - setAudioMuted: vi.fn(), - isCurrentlyAudible: () => false, - on: vi.fn(), - off: vi.fn(), - ipc: { on: vi.fn(), off: vi.fn() }, - send: webviewSend, - navigationHistory: { canGoBack: () => false, canGoForward: () => false }, - setWindowOpenHandler: vi.fn(), - debugger: { - isAttached: () => false, - attach: vi.fn(), - sendCommand, - on: vi.fn( - ( - event: string, - listener: (event: unknown, method: string, params: Record) => void, - ) => { - if (event === "message") debuggerMessage = listener; - }, - ), - off: vi.fn(), - }, - capturePage, - } as never); - const recordingFrames: DesktopPreviewRecordingFrame[] = []; - - yield* manager.subscribeRecordingFrames((frame) => - Effect.sync(() => { - recordingFrames.push(frame); - }), - ); - yield* manager.createTab("tab_screencast_guard"); - yield* manager.registerWebview("tab_screencast_guard", 42); - yield* manager.automationEvaluate("tab_screencast_guard", { expression: "null" }); - - debuggerMessage?.({}, "Page.screencastFrame", { - sessionId: 1, - data: "inactive-frame", - metadata: { deviceWidth: 1280, deviceHeight: 720 }, + }; + let markWarmupStarted!: () => void; + const warmupStarted = new Promise((resolve) => { + markWarmupStarted = resolve; }); - yield* Effect.yieldNow; - expect(recordingFrames).toHaveLength(0); - - yield* manager.startRecording("tab_screencast_guard"); - recordingFrames.length = 0; - debuggerMessage?.({}, "Page.screencastFrame", { - sessionId: 2, - data: "active-frame", - metadata: { deviceWidth: 1280, deviceHeight: 720 }, + let finishWarmup!: (image: TestCapturedPreviewImage) => void; + const capturePage = vi + .fn<() => Promise>() + .mockImplementationOnce( + () => + new Promise((resolve) => { + markWarmupStarted(); + finishWarmup = resolve; + }), + ) + .mockResolvedValue(capturedImage); + const initialWebContents = makeTestPreviewWebContents(capturePage, 42); + const replacementOn = vi.fn(); + const replacementWebContents = Object.assign(makeTestPreviewWebContents(capturePage, 43), { + on: replacementOn, + }); + fromId.mockImplementation((id) => { + if (id === 42) return initialWebContents; + if (id === 43) return replacementWebContents; + return null; }); - yield* Effect.yieldNow; - expect(recordingFrames).toEqual([ - expect.objectContaining({ - tabId: "tab_screencast_guard", - data: "active-frame", - width: 1280, - height: 720, - }), + yield* manager.createTab("tab_recording_replacement_race"); + yield* manager.registerWebview("tab_recording_replacement_race", 42); + const start = yield* manager + .startRecording("tab_recording_replacement_race") + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Effect.promise(() => warmupStarted); + const replacement = yield* manager + .registerWebview("tab_recording_replacement_race", 43) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Effect.yieldNow; + expect(replacementOn).not.toHaveBeenCalled(); + + finishWarmup(capturedImage); + yield* Fiber.join(start); + yield* Fiber.join(replacement); + expect(replacementOn).toHaveBeenCalled(); + expect(initialWebContents.setBackgroundThrottling.mock.calls).toEqual([[false]]); + expect(replacementWebContents.setBackgroundThrottling.mock.calls).toEqual([[false]]); + yield* manager.stopRecording("tab_recording_replacement_race"); + expect(initialWebContents.setBackgroundThrottling.mock.calls).toEqual([[false], [true]]); + expect(replacementWebContents.setBackgroundThrottling.mock.calls).toEqual([ + [false], + [true], ]); - yield* manager.stopRecording("tab_screencast_guard"); }), ), ); @@ -2317,7 +2581,9 @@ describe("PreviewManager", () => { withManager((manager) => Effect.gen(function* () { const setBackgroundThrottling = vi.fn(); - const mainWindowWebContents = { setBackgroundThrottling }; + const mainWindowWebContents = Object.assign(makeTestHostWebContents(), { + setBackgroundThrottling, + }); const jpeg = Buffer.from("shared-preview-frame"); const capturePage = vi.fn(async () => ({ toJPEG: () => jpeg, @@ -2325,7 +2591,9 @@ describe("PreviewManager", () => { })); fromId.mockReturnValue({ id: 42, + mainFrame: { routingId: 42 }, hostWebContents: mainWindowWebContents, + executeJavaScript: vi.fn(async () => ({ width: 1280, height: 720 })), isDestroyed: () => false, getType: () => "webview", getURL: () => "https://example.com", @@ -2334,6 +2602,7 @@ describe("PreviewManager", () => { getZoomFactor: () => 1, setZoomFactor: vi.fn(), setAudioMuted: vi.fn(), + setBackgroundThrottling: vi.fn(), isCurrentlyAudible: () => false, on: vi.fn(), off: vi.fn(), @@ -2369,6 +2638,8 @@ describe("PreviewManager", () => { pictureInPictureListeners.get("closed")?.(); }), webContents: { + on: vi.fn(), + off: vi.fn(), send: pictureInPictureSend, }, }; @@ -2434,24 +2705,24 @@ describe("PreviewManager", () => { ); expect(states.at(-1)?.pictureInPicture).toBe(true); expect(capturePage).toHaveBeenCalledOnce(); + const pictureInPictureFramesBeforeRecording = pictureInPictureSend.mock.calls.length; yield* manager.startRecording("tab_pip"); - expect(capturePage).toHaveBeenCalledOnce(); + expect(capturePage).toHaveBeenCalledTimes(2); expect(recordingFrames).toHaveLength(0); yield* TestClock.adjust(100); - expect(capturePage).toHaveBeenCalledTimes(2); - expect(recordingFrames).toHaveLength(1); + expect(capturePage).toHaveBeenCalledTimes(3); + expect(pictureInPictureSend).toHaveBeenCalledTimes(pictureInPictureFramesBeforeRecording); + expect(recordingFrames).toHaveLength(0); yield* manager.stopRecording("tab_pip"); expect(setBackgroundThrottling.mock.calls).toEqual([[false]]); const framesBeforePictureInPictureOnlyTick = pictureInPictureSend.mock.calls.length; yield* TestClock.adjust(100); - expect(capturePage).toHaveBeenCalledTimes(3); - expect(pictureInPictureSend.mock.calls.length).toBeGreaterThan( - framesBeforePictureInPictureOnlyTick, - ); - expect(recordingFrames).toHaveLength(1); + expect(capturePage).toHaveBeenCalledTimes(4); + expect(pictureInPictureSend.mock.calls.length).toBe(framesBeforePictureInPictureOnlyTick); + expect(recordingFrames).toHaveLength(0); setBackgroundThrottling.mockImplementationOnce(() => { throw new Error("picture-in-picture throttling restore failed"); @@ -2467,44 +2738,137 @@ describe("PreviewManager", () => { ), ); - effectIt.effect("retries a cold hidden-tab capture without dropping recording", () => + effectIt.effect("keeps picture-in-picture capture separate from recording warmup", () => withManager((manager) => Effect.gen(function* () { - const jpeg = Buffer.from("recovered-preview-frame"); + const jpeg = Buffer.from("shared-preview-frame"); const capturePage = vi.fn(async () => ({ toJPEG: () => jpeg, getSize: () => ({ width: 1280, height: 720 }), })); - capturePage.mockRejectedValueOnce(new Error("UnknownVizError")); fromId.mockReturnValue(makeTestPreviewWebContents(capturePage)); - const frames: DesktopPreviewRecordingFrame[] = []; + const { pictureInPictureWindow, send } = makeTestPictureInPictureWindow(); + browserWindowConstructor.mockImplementation(function () { + return pictureInPictureWindow; + }); + const recordingFrames: DesktopPreviewRecordingFrame[] = []; yield* manager.subscribeRecordingFrames((frame) => Effect.sync(() => { - frames.push(frame); + recordingFrames.push(frame); }), ); - yield* manager.createTab("tab_cold_capture"); - yield* manager.registerWebview("tab_cold_capture", 42); - - yield* manager.startRecording("tab_cold_capture"); + yield* manager.createTab("tab_recording_then_pip"); + yield* manager.registerWebview("tab_recording_then_pip", 42); + yield* manager.startRecording("tab_recording_then_pip"); + expect(recordingFrames).toHaveLength(0); expect(capturePage).toHaveBeenCalledOnce(); - expect(frames).toHaveLength(0); + yield* manager.openPictureInPicture("tab_recording_then_pip"); + expect(capturePage).toHaveBeenCalledTimes(2); + expect(send).toHaveBeenCalledOnce(); yield* TestClock.adjust(100); - expect(capturePage).toHaveBeenCalledTimes(2); - expect(frames).toEqual([ - expect.objectContaining({ - tabId: "tab_cold_capture", - data: jpeg.toString("base64"), - width: 1280, - height: 720, + expect(capturePage).toHaveBeenCalledTimes(3); + expect(recordingFrames).toHaveLength(0); + expect(send).toHaveBeenCalledOnce(); + yield* manager.closePictureInPicture("tab_recording_then_pip"); + yield* manager.stopRecording("tab_recording_then_pip"); + }), + ), + ); + + effectIt.effect("stops frame capture when the native picture-in-picture window closes", () => + withManager((manager) => + Effect.gen(function* () { + const capturePage = vi.fn(async () => ({ + toJPEG: () => Buffer.from("native-close-preview-frame"), + getSize: () => ({ width: 1280, height: 720 }), + })); + fromId.mockReturnValue(makeTestPreviewWebContents(capturePage)); + const { pictureInPictureWindow } = makeTestPictureInPictureWindow(); + browserWindowConstructor.mockImplementation(function () { + return pictureInPictureWindow; + }); + const states: PreviewManager.PreviewTabState[] = []; + yield* manager.subscribeStateChanges((_tabId, state) => + Effect.sync(() => { + states.push(state); }), - ]); + ); + + yield* manager.createTab("tab_native_pip_close"); + yield* manager.registerWebview("tab_native_pip_close", 42); + yield* manager.openPictureInPicture("tab_native_pip_close"); + + pictureInPictureWindow.close(); + yield* settle(() => states.at(-1)?.pictureInPicture === false); + + expect(states.at(-1)?.pictureInPicture).toBe(false); + const capturesAfterClose = capturePage.mock.calls.length; + yield* TestClock.adjust(200); + expect(capturePage).toHaveBeenCalledTimes(capturesAfterClose); + }), + ), + ); + + effectIt.effect("retries an unchanged picture-in-picture frame after delivery fails", () => + withManager((manager) => + Effect.gen(function* () { + const jpeg = Buffer.from("retry-preview-frame"); + const capturePage = vi.fn(async () => ({ + toJPEG: () => jpeg, + getSize: () => ({ width: 1280, height: 720 }), + })); + fromId.mockReturnValue(makeTestPreviewWebContents(capturePage)); + const { pictureInPictureWindow, send } = makeTestPictureInPictureWindow(); + send.mockImplementationOnce(() => { + throw new Error("picture-in-picture delivery failed"); + }); + browserWindowConstructor.mockImplementation(function () { + return pictureInPictureWindow; + }); + + yield* manager.createTab("tab_pip_delivery_retry"); + yield* manager.registerWebview("tab_pip_delivery_retry", 42); + yield* manager.openPictureInPicture("tab_pip_delivery_retry"); + expect(send).toHaveBeenCalledOnce(); + + yield* TestClock.adjust(100); + + expect(capturePage).toHaveBeenCalledTimes(2); + expect(send).toHaveBeenCalledTimes(2); + yield* manager.closePictureInPicture("tab_pip_delivery_retry"); + }), + ), + ); + + effectIt.effect("replays an unchanged picture-in-picture frame after its renderer reloads", () => + withManager((manager) => + Effect.gen(function* () { + const jpeg = Buffer.from("reloaded-preview-frame"); + const capturePage = vi.fn(async () => ({ + toJPEG: () => jpeg, + getSize: () => ({ width: 1280, height: 720 }), + })); + fromId.mockReturnValue(makeTestPreviewWebContents(capturePage)); + const { pictureInPictureWindow, send, webContentsListeners } = + makeTestPictureInPictureWindow(); + browserWindowConstructor.mockImplementation(function () { + return pictureInPictureWindow; + }); + + yield* manager.createTab("tab_pip_reload"); + yield* manager.registerWebview("tab_pip_reload", 42); + yield* manager.openPictureInPicture("tab_pip_reload"); + expect(send).toHaveBeenCalledOnce(); + + webContentsListeners.get("did-finish-load")?.(); + yield* TestClock.adjust(100); - yield* manager.stopRecording("tab_cold_capture"); + expect(send).toHaveBeenCalledTimes(2); + yield* manager.closePictureInPicture("tab_pip_reload"); }), ), ); diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index 0d90e0175fe3..8ee312110d86 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -5,8 +5,10 @@ * elements live in the renderer; we only attach listeners and forward state * here). Single layer-scoped browser session partition. */ +import { DESKTOP_PREVIEW_RECORDING_CAPTURE_TRIGGER } from "@t3tools/contracts"; import type { DesktopPreviewAnnotationTheme, + DesktopPreviewAutomationStatus, DesktopPreviewColorScheme, DesktopPreviewFavicon, DesktopPreviewPointerEvent, @@ -25,7 +27,6 @@ import type { PreviewAutomationNetworkEntry, PreviewAutomationScrollInput, PreviewAutomationSnapshot, - PreviewAutomationStatus, PreviewAutomationTypeInput, PreviewAutomationWaitForInput, } from "@t3tools/contracts"; @@ -108,8 +109,10 @@ const MAX_EVALUATION_BYTES = 64_000; const MAX_VISIBLE_TEXT_LENGTH = 20_000; const MAX_INTERACTIVE_ELEMENTS = 200; const MAX_SCREENSHOT_WIDTH = 1280; -const RECORDING_FRAME_INTERVAL_MS = Math.ceil(1_000 / 12); -const RECORDING_JPEG_QUALITY = 80; +/** How long an armed tab keeps the exclusive display-media slot before another tab may take it. */ +const RECORDING_ARM_GRACE_MS = 10_000; +const PICTURE_IN_PICTURE_FRAME_INTERVAL_MS = Math.ceil(1_000 / 12); +const PICTURE_IN_PICTURE_JPEG_QUALITY = 80; const PICTURE_IN_PICTURE_INITIAL_WIDTH = 480; const PICTURE_IN_PICTURE_INITIAL_HEIGHT = 320; const PICTURE_IN_PICTURE_MIN_WIDTH = 240; @@ -119,6 +122,8 @@ const DIAGNOSTIC_BUFFER_LIMIT = 200; const MAX_ARTIFACT_SITE_SLUG_LENGTH = 80; const AGENT_CURSOR_MOVE_MS = 160; const AGENT_CURSOR_CLICK_LEAD_MS = 40; +const requestRecordingCaptureExpression = (tabId: string): string => + `globalThis[${JSON.stringify(DESKTOP_PREVIEW_RECORDING_CAPTURE_TRIGGER)}]?.(${JSON.stringify(tabId)}) === true`; const encodeUnknownJson = Schema.encodeUnknownEffect(Schema.fromJsonString(Schema.Unknown)); const DEFAULT_ANNOTATION_THEME: DesktopPreviewAnnotationTheme = { colorScheme: "light", @@ -188,6 +193,12 @@ export const fitPictureInPictureContentSize = ( return [Math.round(width), Math.round(height)]; }; +export const recordingFileExtension = (mimeType: string): string => { + const subtype = mimeType.split(";", 1)[0]?.trim().toLowerCase().split("/")[1] ?? ""; + const extension = subtype.replace(/^x-/, "").replace(/[^a-z0-9]/g, ""); + return extension || "video"; +}; + const artifactSiteSlug = (rawUrl: string): string => { try { const url = new URL(rawUrl); @@ -379,8 +390,10 @@ interface ManagedListeners { type FrameCaptureConsumer = "picture-in-picture" | "recording"; interface FrameCaptureSession { - readonly scope: Scope.Closeable; + readonly scope: Scope.Closeable | null; readonly consumers: ReadonlySet; + readonly unthrottledWebContentsIds: ReadonlySet; + readonly lastPictureInPictureFrame: Buffer | null; } interface PictureInPictureSession { @@ -390,6 +403,14 @@ interface PictureInPictureSession { readonly initializationScope: Scope.Closeable; } +/** The tab whose frame the next `getDisplayMedia()` request is allowed to capture. */ +interface PendingRecording { + readonly tabId: string; + readonly webContents: Electron.WebContents; + readonly requestingFrameTreeNodeId: number; + readonly armedAtMillis: number; +} + interface PickSession { readonly cancel: Effect.Effect; } @@ -434,6 +455,61 @@ const APP_FORWARDED_SHORTCUTS: ReadonlyArray<{ { key: "w", meta: true, shift: false, control: false }, ]); +/** + * Protocols a preview page may open in a real popup window. + * + * `about:blank` stays out: Chromium skips browser-side navigation for it, so the + * child copies the guest's `contextIsolation: false` preferences and Electron + * gives no way to override them. Those popups keep loading in the preview tab. + * + * Deliberately not `ElectronShell.parseSafeExternalUrl`: that also admits + * `vscode://vscode-remote/...` deep links, which belong in `shell.openExternal` + * and not in a window spawned by a third-party page in the preview. + */ +const POPUP_PROTOCOLS = new Set(["http:", "https:"]); + +const isPopupUrl = (rawUrl: string): boolean => { + try { + return POPUP_PROTOCOLS.has(new URL(rawUrl).protocol); + } catch { + return false; + } +}; + +/** + * Preferences for a popup a preview page opens. + * + * A popup is not a webview attach, so the `will-attach-webview` hardening in + * `DesktopWindow` never sees it, and an unoverridden child would inherit the + * guest's relaxed posture: the picker preload needs `contextIsolation: false` + * to share `globalThis` with the previewed page, and no OAuth provider should + * get that. The window keeps the opener and the guest session either way. + */ +const POPUP_WINDOW_OPTIONS = { + webPreferences: { + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + }, +} satisfies Electron.BrowserWindowConstructorOptions; + +/** + * Decides what a preview page's `window.open` should do. + * + * `"popup"` opens a real window, which scripted popups need: denying them makes + * `window.open()` return `null` (OAuth SDKs report that as a blocked popup), and + * navigating the preview tab instead destroys the opener the popup has to + * `postMessage` its result back to. + * + * `target="_blank"` links arrive as a tab disposition and keep loading in the + * preview tab, which is what people expect from a link inside a preview. + */ +export const previewWindowOpenAction = (details: { + readonly url: string; + readonly disposition: Electron.HandlerDetails["disposition"]; +}): "popup" | "navigate" => + details.disposition === "new-window" && isPopupUrl(details.url) ? "popup" : "navigate"; + export const isPreviewRefreshShortcut = (input: Electron.Input): boolean => input.type === "keyDown" && input.key.toLowerCase() === "r" && @@ -526,6 +602,11 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const pictureInPictureAspectRatiosRef = yield* Ref.make>(new Map()); const pictureInPictureMutationSemaphore = yield* Semaphore.make(1); const closingTabIdsRef = yield* Ref.make>(new Set()); + // Tab recording uses `setDisplayMediaRequestHandler` because Electron's legacy + // `getMediaSourceId` + `chromeMediaSource: "tab"` capture path was removed upstream + // (electron#44618) and now always rejects with NotAllowedError. + let pendingRecording: PendingRecording | null = null; + const displayMediaHandlerSessions = new WeakSet(); let frameCaptureWindowOpen = true; let currentMainWindow: BrowserWindow | undefined; let mainWindowCleanupFiber: Fiber.Fiber | undefined; @@ -600,6 +681,65 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function if (Option.isNone(mainWindow)) return; yield* setWindowBackgroundThrottling(mainWindow.value, enabled); }); + const setFrameCaptureWebContentsBackgroundThrottling = Effect.fnUntraced(function* ( + wc: Electron.WebContents, + enabled: boolean, + ) { + if (wc.isDestroyed()) return; + yield* attempt( + { + operation: "frameCapture.setBackgroundThrottling", + webContentsId: wc.id, + }, + () => wc.setBackgroundThrottling(enabled), + ); + }); + const restoreFrameCaptureWebContentsBackgroundThrottling = Effect.fnUntraced(function* ( + webContentsIds: ReadonlySet, + ) { + yield* Effect.forEach( + webContentsIds, + (webContentsId) => { + const wc = webContents.fromId(webContentsId); + if (!wc || wc.isDestroyed()) return Effect.void; + return setFrameCaptureWebContentsBackgroundThrottling(wc, true).pipe( + Effect.retry({ times: 2 }), + Effect.catch((error) => + Effect.logWarning("Failed to restore preview webview frame capture throttling.", { + webContentsId, + error, + }), + ), + ); + }, + { concurrency: "unbounded", discard: true }, + ); + }); + const keepFrameCaptureWebContentsUnthrottled = Effect.fnUntraced(function* ( + tabId: string, + wc: Electron.WebContents, + ) { + yield* SynchronizedRef.modifyEffect(frameCaptureSessionsRef, (sessions) => { + const current = sessions.get(tabId); + if (!current || current.unthrottledWebContentsIds.has(wc.id)) { + return Effect.succeed([undefined, sessions] as const); + } + return setFrameCaptureWebContentsBackgroundThrottling(wc, false).pipe( + Effect.map( + () => + [ + undefined, + replaceMap(sessions, (copy) => { + copy.set(tabId, { + ...current, + unthrottledWebContentsIds: new Set([...current.unthrottledWebContentsIds, wc.id]), + }); + }), + ] as const, + ), + ); + }); + }); const stopFrameCapture = Effect.fn("PreviewManager.stopFrameCapture")(function* ( tabId: string, consumer: FrameCaptureConsumer, @@ -614,15 +754,24 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function consumers.delete(consumer); if (consumers.size > 0) { return [ - undefined, + consumer === "picture-in-picture" ? current.scope : undefined, replaceMap(sessions, (copy) => { - copy.set(tabId, { ...current, consumers }); + copy.set(tabId, { + ...current, + scope: consumer === "picture-in-picture" ? null : current.scope, + consumers, + lastPictureInPictureFrame: + consumer === "picture-in-picture" ? null : current.lastPictureInPictureFrame, + }); }), ] as const; } const remainingSessions = replaceMap(sessions, (copy) => { copy.delete(tabId); }); + yield* restoreFrameCaptureWebContentsBackgroundThrottling( + current.unthrottledWebContentsIds, + ); if (remainingSessions.size === 0) { yield* setFrameCaptureBackgroundThrottling(true).pipe( Effect.retry({ times: 2 }), @@ -642,6 +791,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }); const stopAllRecordings = Effect.fn("PreviewManager.stopAllRecordings")(function* () { + pendingRecording = null; const sessions = yield* SynchronizedRef.get(frameCaptureSessionsRef); yield* Effect.forEach(sessions.keys(), (tabId) => stopFrameCapture(tabId, "recording"), { concurrency: "unbounded", @@ -1661,6 +1811,12 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ], }); }); + // A popup opens with Electron's default handler, so the page inside it could + // otherwise spawn native windows without limit. Nothing in an OAuth flow + // opens a second popup, so the chain stops at the first one. + const windowCreated = (window: Electron.BrowserWindow): void => { + window.webContents.setWindowOpenHandler(() => ({ action: "deny" })); + }; const beforeInput = (event: Electron.Event, input: Electron.Input): void => { if (isPreviewRefreshShortcut(input)) { event.preventDefault(); @@ -1686,6 +1842,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function wc.off("did-stop-loading", sync); wc.off("did-fail-load", failed as never); wc.off("audio-state-changed", audioStateChanged); + wc.off("did-create-window", windowCreated); wc.off("before-input-event", beforeInput); wc.ipc.off(HUMAN_INPUT_CHANNEL, humanInput); wc.ipc.off(MOUSE_NAVIGATE_CHANNEL, mouseNavigate); @@ -1704,14 +1861,18 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function wc.on("audio-state-changed", audioStateChanged); wc.ipc.on(HUMAN_INPUT_CHANNEL, humanInput); wc.ipc.on(MOUSE_NAVIGATE_CHANNEL, mouseNavigate); - wc.setWindowOpenHandler(({ url }) => { + wc.setWindowOpenHandler((details) => { + if (previewWindowOpenAction(details) === "popup") { + return { action: "allow", overrideBrowserWindowOptions: POPUP_WINDOW_OPTIONS }; + } runFork( attemptPromise({ operation: "openPreviewWindow", tabId, webContentsId: wc.id }, () => - wc.loadURL(url), + wc.loadURL(details.url), ).pipe(Effect.ignore), ); return { action: "deny" }; }); + wc.on("did-create-window", windowCreated); wc.on("before-input-event", beforeInput); }); yield* Ref.update(attachedRef, (attached) => @@ -1807,6 +1968,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const closeTabUnlocked = Effect.fn("PreviewManager.closeTabUnlocked")(function* (tabId: string) { if (!(yield* SynchronizedRef.get(tabsRef)).has(tabId)) return; + clearPendingRecording(tabId); yield* Effect.all( [ cancelPickElement(tabId), @@ -1898,6 +2060,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const attached = yield* Ref.get(attachedRef); const annotationTheme = yield* Ref.get(annotationThemeRef); const currentAttachment = attached.get(webContentsId); + yield* keepFrameCaptureWebContentsUnthrottled(tabId, wc); if (tab.webContentsId === webContentsId && currentAttachment?.webContents === wc) { // The guest we already own re-announced itself, so nothing about the tab // changed. Only push its zoom back down — Chromium may have just handed @@ -1914,6 +2077,8 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ? tab.webContentsId : null; if (replacedWebContentsId !== null) { + // The replaced guest can no longer redeem a display-media grant. + clearPendingRecording(tabId); yield* Effect.all( [ detachControlSession(replacedWebContentsId), @@ -2503,7 +2668,8 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function tabId: string, ) { const captureSession = (yield* SynchronizedRef.get(frameCaptureSessionsRef)).get(tabId); - if (!captureSession) return; + if (!captureSession?.consumers.has("picture-in-picture") || captureSession.scope === null) + return; const wc = yield* requireWebContents(tabId); const image = yield* attemptPromise( { @@ -2549,28 +2715,24 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function tabId, webContentsId: wc.id, }, - () => image.toJPEG(RECORDING_JPEG_QUALITY).toString("base64"), + () => image.toJPEG(PICTURE_IN_PICTURE_JPEG_QUALITY), ); + const frameSession = (yield* SynchronizedRef.get(frameCaptureSessionsRef)).get(tabId); + if (frameSession?.scope !== captureSession.scope) return; + const pictureInPicture = + frameSession.consumers.has("picture-in-picture") && + frameSession.lastPictureInPictureFrame?.equals(encoded) !== true; + if (!pictureInPicture) return; const receivedAt = yield* currentIso; const frame: DesktopPreviewRecordingFrame = { tabId, - data: encoded, + data: encoded.toString("base64"), width: size.width, height: size.height, receivedAt, }; const deliveries: Array> = []; - if (currentCaptureSession.consumers.has("recording")) { - const listeners = yield* Ref.get(recordingFrameListenersRef); - deliveries.push( - Effect.forEach( - listeners, - (listener) => deliverEvent("recording-frame", frame.tabId, () => listener(frame)), - { discard: true }, - ), - ); - } - if (currentCaptureSession.consumers.has("picture-in-picture")) { + if (pictureInPicture) { const pictureInPictureWindow = (yield* SynchronizedRef.get(pictureInPictureSessionsRef)).get( tabId, )?.window; @@ -2620,6 +2782,15 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ); }, ); + yield* SynchronizedRef.update(frameCaptureSessionsRef, (sessions) => { + if (sessions.get(tabId) !== frameSession) return sessions; + return replaceMap(sessions, (copy) => { + copy.set(tabId, { + ...frameSession, + lastPictureInPictureFrame: encoded, + }); + }); + }); }).pipe( Effect.catch((error) => Effect.logWarning("Picture-in-picture frame delivery failed.", { @@ -2638,12 +2809,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function tabId: string, consumer: FrameCaptureConsumer, ) { - // Validate the tab synchronously, but treat capturePage failures as - // transient. Chromium can return UnknownVizError while a hidden guest is - // warming its first compositor frame; the scheduled loop should keep the - // consumer alive and recover instead of tearing recording/PiP back down. - yield* requireWebContents(tabId); - const captureNextFrame = Effect.sleep(RECORDING_FRAME_INTERVAL_MS).pipe( + // Recording keeps only the activity lease. Picture-in-picture owns the + // capturePage loop and tolerates transient compositor warmup failures. + const captureNextFrame = Effect.sleep(PICTURE_IN_PICTURE_FRAME_INTERVAL_MS).pipe( Effect.andThen(capturePreviewFrame(tabId)), Effect.catch((error) => Effect.logWarning("Background preview frame capture failed.", { @@ -2652,47 +2820,73 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }), ), ); - const created = yield* SynchronizedRef.modifyEffect(frameCaptureSessionsRef, (sessions) => { - return Effect.gen(function* () { - if (!frameCaptureWindowOpen) { - return yield* new PreviewMainWindowClosedError({ tabId }); - } - const tab = (yield* SynchronizedRef.get(tabsRef)).get(tabId); - if (!tab || (yield* Ref.get(closingTabIdsRef)).has(tabId)) { - return yield* new PreviewTabNotFoundError({ tabId }); - } - const current = sessions.get(tabId); - if (current) { - if (current.consumers.has(consumer)) { - return [false, sessions] as const; + const captureInitialFrame = yield* SynchronizedRef.modifyEffect( + frameCaptureSessionsRef, + (sessions) => { + return Effect.gen(function* () { + if (!frameCaptureWindowOpen) { + return yield* new PreviewMainWindowClosedError({ tabId }); + } + const tab = (yield* SynchronizedRef.get(tabsRef)).get(tabId); + if (!tab || (yield* Ref.get(closingTabIdsRef)).has(tabId)) { + return yield* new PreviewTabNotFoundError({ tabId }); + } + const wc = yield* requireWebContents(tabId); + const current = sessions.get(tabId); + if (current) { + if (current.consumers.has(consumer)) { + return [false, sessions] as const; + } + if (!current.unthrottledWebContentsIds.has(wc.id)) { + yield* setFrameCaptureWebContentsBackgroundThrottling(wc, false); + } + let scope = current.scope; + if (consumer === "picture-in-picture" && scope === null) { + scope = yield* Scope.fork(parentScope, "sequential"); + yield* Effect.forkIn(Effect.forever(captureNextFrame), scope); + } + return [ + consumer === "picture-in-picture", + replaceMap(sessions, (copy) => { + copy.set(tabId, { + ...current, + scope, + consumers: new Set([...current.consumers, consumer]), + unthrottledWebContentsIds: new Set([...current.unthrottledWebContentsIds, wc.id]), + }); + }), + ] as const; + } + if (sessions.size === 0) { + yield* setFrameCaptureBackgroundThrottling(false); + } + yield* setFrameCaptureWebContentsBackgroundThrottling(wc, false).pipe( + Effect.onError(() => + sessions.size === 0 + ? setFrameCaptureBackgroundThrottling(true).pipe(Effect.ignore) + : Effect.void, + ), + ); + const scope = + consumer === "picture-in-picture" ? yield* Scope.fork(parentScope, "sequential") : null; + if (scope !== null) { + yield* Effect.forkIn(Effect.forever(captureNextFrame), scope); } return [ - false, + consumer === "picture-in-picture", replaceMap(sessions, (copy) => { copy.set(tabId, { - ...current, - consumers: new Set([...current.consumers, consumer]), + scope, + consumers: new Set([consumer]), + unthrottledWebContentsIds: new Set([wc.id]), + lastPictureInPictureFrame: null, }); }), ] as const; - } - if (sessions.size === 0) { - yield* setFrameCaptureBackgroundThrottling(false); - } - const scope = yield* Scope.fork(parentScope, "sequential"); - yield* Effect.forkIn(Effect.forever(captureNextFrame), scope); - return [ - true, - replaceMap(sessions, (copy) => { - copy.set(tabId, { - scope, - consumers: new Set([consumer]), - }); - }), - ] as const; - }); - }).pipe(Effect.uninterruptible); - if (!created) return; + }); + }, + ).pipe(Effect.uninterruptible); + if (!captureInitialFrame) return; yield* capturePreviewFrame(tabId).pipe( Effect.catch((error) => Effect.logWarning("Initial background preview frame was not ready; capture will retry.", { @@ -2841,6 +3035,18 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ), ); }; + const onDidFinishLoad = () => { + runFork( + SynchronizedRef.update(frameCaptureSessionsRef, (sessions) => { + const current = sessions.get(tabId); + if (!current?.consumers.has("picture-in-picture")) return sessions; + return replaceMap(sessions, (copy) => { + copy.set(tabId, { ...current, lastPictureInPictureFrame: null }); + }); + }), + ); + }; + const pipWebContents = pictureInPictureWindow.webContents; yield* attempt( { operation: "pictureInPicture.configure", @@ -2861,6 +3067,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function skipTransformProcessType: true, }); } + pipWebContents.on("did-finish-load", onDidFinishLoad); }, ).pipe( Effect.onError(() => @@ -2875,6 +3082,12 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ), ), ); + yield* Scope.addFinalizer( + initializationScope, + Effect.sync(() => { + pipWebContents.off("did-finish-load", onDidFinishLoad); + }).pipe(Effect.ignore), + ); yield* SynchronizedRef.update(pictureInPictureSessionsRef, (sessions) => replaceMap(sessions, (copy) => { copy.set(tabId, session); @@ -2981,12 +3194,149 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function return yield* Effect.failCause(initializationExit.cause); }); + /** Only drops the armed target when it still belongs to `tabId`, so tabs cannot clobber each other. */ + const clearPendingRecording = (tabId: string) => { + if (pendingRecording?.tabId === tabId) pendingRecording = null; + }; + + /** + * Claims the single arm slot for `tabId`. A display-media request carries no tab identity, so the + * slot is exclusive: a second tab arming before the first request lands would redirect the first + * renderer's stream. Rather than queue (which can only ever stall a start), a colliding start + * fails fast and the renderer can retry. An arm the renderer never redeemed goes stale after + * `RECORDING_ARM_GRACE_MS` so it cannot hold the slot forever. + */ + const armPendingRecording = Effect.fn("PreviewManager.armPendingRecording")(function* ( + tabId: string, + wc: Electron.WebContents, + requestingFrameTreeNodeId: number, + ) { + const now = yield* Clock.currentTimeMillis; + const previous = pendingRecording; + if ( + previous !== null && + previous.tabId !== tabId && + !previous.webContents.isDestroyed() && + now - previous.armedAtMillis < RECORDING_ARM_GRACE_MS + ) { + return yield* new PreviewRecordingArmConflictError({ + tabId, + webContentsId: wc.id, + armedTabId: previous.tabId, + }); + } + const armed: PendingRecording = { + tabId, + webContents: wc, + requestingFrameTreeNodeId, + armedAtMillis: now, + }; + pendingRecording = armed; + // The handler callback is sync and cannot read a clock, so expiry is driven from here. + // Identity compare: a re-arm replaces the object, and this fiber must not clobber it. + yield* Effect.forkIn( + Effect.sleep(RECORDING_ARM_GRACE_MS).pipe( + Effect.andThen( + Effect.sync(() => { + if (pendingRecording === armed) pendingRecording = null; + }), + ), + ), + parentScope, + ); + }); + + // Installed once per session: answers the renderer's `getDisplayMedia()` with the tab that + // `startRecording` armed, and denies anything else so pages cannot capture on their own. + const installDisplayMediaRequestHandler = (session: Session) => { + if (displayMediaHandlerSessions.has(session)) return; + displayMediaHandlerSessions.add(session); + session.setDisplayMediaRequestHandler((request, callback) => { + const armed = pendingRecording; + if (!armed) { + callback({}); + return; + } + if (armed.webContents.isDestroyed()) { + pendingRecording = null; + callback({}); + return; + } + if (request.frame?.frameTreeNodeId !== armed.requestingFrameTreeNodeId) { + callback({}); + return; + } + pendingRecording = null; + callback({ video: armed.webContents.mainFrame }); + }); + }; + const startRecording = Effect.fn("PreviewManager.startRecording")(function* (tabId: string) { - yield* startFrameCapture(tabId, "recording"); + if ((yield* Ref.get(closingTabIdsRef)).has(tabId)) { + return yield* new PreviewTabNotFoundError({ tabId }); + } + return yield* withTabLifecycleLock( + tabId, + Effect.gen(function* () { + yield* startFrameCapture(tabId, "recording"); + const wc = yield* requireWebContents(tabId); + const requestWebContents = wc.hostWebContents; + if (requestWebContents === null) { + return yield* new PreviewMainWindowClosedError({ tabId }); + } + yield* attemptPromise( + { + operation: "recording.warmSource", + tabId, + webContentsId: wc.id, + }, + () => wc.capturePage().then(() => undefined), + ).pipe(Effect.retry({ times: 1 }), Effect.ignore); + const currentWebContents = yield* requireWebContents(tabId); + if (currentWebContents !== wc || wc.isDestroyed()) { + return yield* new PreviewWebContentsNotFoundError({ + tabId, + webContentsId: wc.id, + }); + } + if (!frameCaptureWindowOpen || requestWebContents.isDestroyed()) { + return yield* new PreviewMainWindowClosedError({ tabId }); + } + installDisplayMediaRequestHandler(requestWebContents.session); + yield* armPendingRecording(tabId, wc, requestWebContents.mainFrame.frameTreeNodeId); + const captureRequested = yield* attemptPromise( + { + operation: "recording.requestCapture", + tabId, + webContentsId: requestWebContents.id, + }, + () => + requestWebContents.executeJavaScript(requestRecordingCaptureExpression(tabId), true), + ); + if (captureRequested !== true) { + return yield* new PreviewRecordingCaptureUnavailableError({ + tabId, + webContentsId: requestWebContents.id, + }); + } + }).pipe( + Effect.onError(() => { + clearPendingRecording(tabId); + return stopFrameCapture(tabId, "recording").pipe(Effect.ignore); + }), + ), + ); }); const stopRecording = Effect.fn("PreviewManager.stopRecording")(function* (tabId: string) { - yield* stopFrameCapture(tabId, "recording"); + // Clearing runs under the tab lock so it cannot land before an in-flight start arms. + yield* withTabLifecycleLock( + tabId, + Effect.suspend(() => { + clearPendingRecording(tabId); + return stopFrameCapture(tabId, "recording"); + }), + ); }); const saveRecording = Effect.fn("PreviewManager.saveRecording")(function* ( @@ -2996,7 +3346,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ) { const [createdAt, millis] = yield* Effect.all([currentIso, currentMillis]); const id = `browser-recording-${millis.toString(36)}`; - const extension = mimeType.includes("mp4") ? "mp4" : "webm"; + const extension = recordingFileExtension(mimeType); const artifactPath = path.join(resolvedArtifactDirectory, `${id}.${extension}`); yield* fileSystem.makeDirectory(resolvedArtifactDirectory, { recursive: true }).pipe( Effect.mapError( @@ -3796,6 +4146,31 @@ export class PreviewMainWindowClosedError extends Schema.TaggedErrorClass()( + "PreviewRecordingArmConflictError", + { + tabId: Schema.String, + webContentsId: Schema.Number, + armedTabId: Schema.String, + }, +) { + override get message(): string { + return `Preview tab ${this.armedTabId} is still claiming the capture stream, so recording could not start for tab ${this.tabId}`; + } +} + +export class PreviewRecordingCaptureUnavailableError extends Schema.TaggedErrorClass()( + "PreviewRecordingCaptureUnavailableError", + { + tabId: Schema.String, + webContentsId: Schema.Number, + }, +) { + override get message(): string { + return `Preview recording capture is unavailable for tab ${this.tabId} in WebContents ${this.webContentsId}`; + } +} + export class PreviewOperationError extends Schema.TaggedErrorClass()( "PreviewOperationError", { @@ -4003,6 +4378,8 @@ export const PreviewManagerError = Schema.Union([ PreviewWebContentsNotFoundError, PreviewWebviewNotInitializedError, PreviewMainWindowClosedError, + PreviewRecordingArmConflictError, + PreviewRecordingCaptureUnavailableError, PreviewOperationError, PreviewArtifactPathOutsideDirectoryError, PreviewArtifactImageLoadError, @@ -4089,7 +4466,7 @@ export class PreviewManager extends Context.Service< ) => Effect.Effect; readonly automationStatus: ( tabId: string, - ) => Effect.Effect; + ) => Effect.Effect; readonly automationSnapshot: ( tabId: string, ) => Effect.Effect; diff --git a/apps/desktop/src/preview/PickLabelPosition.ts b/apps/desktop/src/preview/PickLabelPosition.ts deleted file mode 100644 index cf7f3c811f88..000000000000 --- a/apps/desktop/src/preview/PickLabelPosition.ts +++ /dev/null @@ -1,46 +0,0 @@ -/** - * Pure clamp/flip math for the floating label that follows the cursor while - * the user is picking an element in the in-app browser. Lives in its own - * electron-free module so the geometry can be unit-tested without spinning - * up an Electron preload context (`PickPreload.ts` itself imports - * `electron` and `react-grab/primitives`, which can't load under vitest). - * - * - Horizontally pins the label to `targetLeft`, clamped into - * `[VIEWPORT_MARGIN, viewportWidth - labelWidth - VIEWPORT_MARGIN]`. - * - Vertically prefers above the target. If the label would overflow the - * top, flips below; if THAT also overflows the bottom, pins to the - * bottom margin (better to overlap the highlight than disappear). - */ - -/** Distance in CSS pixels between the highlight and the floating label. */ -export const LABEL_GAP = 4; -/** Minimum padding the label keeps from any viewport edge. */ -export const VIEWPORT_MARGIN = 4; - -export function computeLabelPosition(input: { - targetLeft: number; - targetTop: number; - targetBottom: number; - labelWidth: number; - labelHeight: number; - viewportWidth: number; - viewportHeight: number; -}): { x: number; y: number } { - const { targetLeft, targetTop, targetBottom, labelWidth, labelHeight } = input; - const { viewportWidth, viewportHeight } = input; - - let x = targetLeft; - const maxX = viewportWidth - labelWidth - VIEWPORT_MARGIN; - if (x > maxX) x = maxX; - if (x < VIEWPORT_MARGIN) x = VIEWPORT_MARGIN; - - let y = targetTop - labelHeight - LABEL_GAP; - if (y < VIEWPORT_MARGIN) { - y = targetBottom + LABEL_GAP; - if (y + labelHeight > viewportHeight - VIEWPORT_MARGIN) { - y = Math.max(VIEWPORT_MARGIN, viewportHeight - labelHeight - VIEWPORT_MARGIN); - } - } - - return { x, y }; -} diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 11030fcc5fa4..1d0c1653f025 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -17,10 +17,12 @@ const clientSettings: ClientSettings = { browserDefaultViewport: { _tag: "preset", width: 1024, height: 600, presetId: "nest-hub" }, browserDefaultZoomFactor: 1.25, browserDefaultAppearance: "dark", + browserRecordingFrameRate: 60, browserAutoShowFloatingPreview: false, - confirmQuit: true, + confirmQuit: "double-click", confirmThreadArchive: true, confirmThreadDelete: false, + confirmThreadUnpin: false, dismissedProviderUpdateNotificationKeys: [], diffIgnoreWhitespace: true, environmentIdentificationMode: "artwork", @@ -38,8 +40,6 @@ const clientSettings: ClientSettings = { planModeEnabled: false, showSkillsInSlashMenu: false, providerModelPreferences: {}, - sidebarAutoSettleAfterDays: 3, - sidebarAutoSettleOnMerge: true, sidebarProjectGroupingMode: "repository_path", sidebarProjectGroupingOverrides: { "environment-1:/tmp/project-a": "separate", diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts index 28955debf7b1..5a76402b1d34 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts @@ -320,7 +320,7 @@ describe("DesktopShellEnvironment", () => { FNM_DIR: "C:\\Users\\testuser\\AppData\\Roaming\\fnm", FNM_MULTISHELL_PATH: "C:\\Users\\testuser\\AppData\\Local\\fnm_multishells\\123", }) - : envOutput({ PATH: "C:\\Custom\\Bin;C:\\Windows\\System32" }); + : envOutput({ PATH: 'C:\\Custom\\Bin;C:";C:\\Windows\\System32' }); }, }); @@ -337,6 +337,7 @@ describe("DesktopShellEnvironment", () => { "C:\\Users\\testuser\\.bun\\bin", "C:\\Users\\testuser\\scoop\\shims", "C:\\Custom\\Bin", + "C:", ].join(";"), ); assert.equal(env.FNM_DIR, "C:\\Users\\testuser\\AppData\\Roaming\\fnm"); diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.ts b/apps/desktop/src/shell/DesktopShellEnvironment.ts index e065bf55d046..b4610eee5c84 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.ts @@ -151,6 +151,9 @@ const pathComparisonKey = (entry: string, platform: NodeJS.Platform) => { return platform === "win32" ? normalized.toLowerCase() : normalized; }; +const sanitizePathEntry = (entry: string, platform: NodeJS.Platform) => + platform === "win32" ? entry.replaceAll('"', "") : entry; + const mergePaths = ( platform: NodeJS.Platform, values: ReadonlyArray>, @@ -163,14 +166,14 @@ const mergePaths = ( if (Option.isNone(value)) continue; for (const entry of value.value.split(delimiter)) { - const trimmed = entry.trim(); - if (trimmed.length === 0) continue; + const sanitized = sanitizePathEntry(entry.trim(), platform); + if (sanitized.length === 0) continue; - const key = pathComparisonKey(trimmed, platform); + const key = pathComparisonKey(sanitized, platform); if (key.length === 0 || seen.has(key)) continue; seen.add(key); - entries.push(trimmed); + entries.push(sanitized); } } diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 56411711eb6c..f1155175a5f2 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -27,7 +27,7 @@ import * as PreviewManager from "../preview/Manager.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import * as DesktopClientSettings from "../settings/DesktopClientSettings.ts"; import * as ElectronApp from "../electron/ElectronApp.ts"; -import { makeQuitHoldHandler } from "./QuitHold.ts"; +import { makeQuitShortcutHandler } from "./QuitHold.ts"; const TITLEBAR_HEIGHT = 40; const TITLEBAR_COLOR = "#01000000"; // #00000000 does not work correctly on Linux @@ -551,12 +551,11 @@ export const make = Effect.gen(function* () { // close-terminal shortcut can outlive the terminal that handled its first // press, so reject repeats before they reach the native window accelerator. // Deliberate presses still flow through the renderer or native menu. - // Chrome-style hold-to-quit: intercept the quit accelerator before the - // native menu sees it and only quit after the shortcut is held. The - // renderer shows the "Hold to Quit" hint via QUIT_SHORTCUT_CHANNEL. - const quitHoldHandler = makeQuitHoldHandler({ + // Intercept the quit accelerator before the native menu sees it and apply + // the configured direct, hold, or double-press behavior. + const quitShortcutHandler = makeQuitShortcutHandler({ platform: environment.platform, - isEnabled: () => + getMode: () => runPromise( Effect.map( clientSettings.get, @@ -566,9 +565,9 @@ export const make = Effect.gen(function* () { }), ), ), - notify: (state) => { + notify: (hint) => { if (!window.isDestroyed()) { - window.webContents.send(QUIT_SHORTCUT_CHANNEL, state); + window.webContents.send(QUIT_SHORTCUT_CHANNEL, hint); } }, quit: () => { @@ -576,7 +575,7 @@ export const make = Effect.gen(function* () { }, }); window.webContents.on("before-input-event", (event, input) => { - quitHoldHandler(event, input); + quitShortcutHandler(event, input); if (input.type !== "keyDown" || !input.isAutoRepeat) return; const modifier = environment.platform === "darwin" ? input.meta : input.control; if (modifier && !input.alt && !input.shift && input.key.toLowerCase() === "w") { diff --git a/apps/desktop/src/window/QuitHold.test.ts b/apps/desktop/src/window/QuitHold.test.ts index 75fed4b08f21..c4388ad39aea 100644 --- a/apps/desktop/src/window/QuitHold.test.ts +++ b/apps/desktop/src/window/QuitHold.test.ts @@ -1,12 +1,17 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { - makeQuitHoldHandler, - QUIT_DOUBLE_TAP_MS, + makeQuitShortcutHandler, + QUIT_DOUBLE_PRESS_MS, QUIT_HOLD_DURATION_MS, QUIT_HOLD_RELEASE_GRACE_MS, } from "./QuitHold.ts"; -import type { QuitHoldKeyInput, QuitHoldState } from "./QuitHold.ts"; +import type { QuitHoldKeyInput } from "./QuitHold.ts"; +import type { QuitConfirmationMode, QuitShortcutHintEvent } from "@t3tools/contracts"; + +const HOLD_DOWN = { state: "down", mode: "hold" } as const; +const DOUBLE_CLICK_DOWN = { state: "down", mode: "double-click" } as const; +const UP = { state: "up" } as const; function makeInput(overrides: Partial): QuitHoldKeyInput { return { @@ -22,22 +27,22 @@ function makeInput(overrides: Partial): QuitHoldKeyInput { } function makeHarness(options?: { - enabled?: boolean; + mode?: QuitConfirmationMode; platform?: NodeJS.Platform; - isEnabled?: () => Promise; + getMode?: () => Promise; }) { - const notifications: Array = []; + const notifications: Array = []; const quit = vi.fn(); - const handler = makeQuitHoldHandler({ + const handler = makeQuitShortcutHandler({ platform: options?.platform ?? "darwin", - isEnabled: options?.isEnabled ?? (() => Promise.resolve(options?.enabled ?? true)), - notify: (state) => notifications.push(state), + getMode: options?.getMode ?? (() => Promise.resolve(options?.mode ?? "hold")), + notify: (event) => notifications.push(event), quit, }); const preventDefault = vi.fn(); const send = async (input: QuitHoldKeyInput) => { handler({ preventDefault }, input); - // Let the isEnabled promise settle. + // Let the getMode promise settle. await Promise.resolve(); await Promise.resolve(); }; @@ -55,7 +60,7 @@ function makeHarness(options?: { return { notifications, quit, preventDefault, send, holdFor }; } -describe("makeQuitHoldHandler", () => { +describe("makeQuitShortcutHandler", () => { beforeEach(() => { vi.useFakeTimers(); }); @@ -69,12 +74,12 @@ describe("makeQuitHoldHandler", () => { const harness = makeHarness(); await harness.send(makeInput({})); expect(harness.preventDefault).toHaveBeenCalledTimes(1); - expect(harness.notifications).toEqual(["down"]); + expect(harness.notifications).toEqual([HOLD_DOWN]); vi.advanceTimersByTime(QUIT_HOLD_DURATION_MS + QUIT_HOLD_RELEASE_GRACE_MS); expect(harness.quit).not.toHaveBeenCalled(); // The watchdog dismisses the hint once the press is clearly over. - expect(harness.notifications).toEqual(["down", "up"]); + expect(harness.notifications).toEqual([HOLD_DOWN, UP]); }); it("quits after a completed hold is released", async () => { @@ -86,7 +91,7 @@ describe("makeQuitHoldHandler", () => { expect(harness.quit).not.toHaveBeenCalled(); vi.advanceTimersByTime(QUIT_HOLD_RELEASE_GRACE_MS); expect(harness.quit).toHaveBeenCalledTimes(1); - expect(harness.notifications).toEqual(["down", "up"]); + expect(harness.notifications).toEqual([HOLD_DOWN, UP]); }); it("waits for Q release when Cmd is released first", async () => { @@ -108,7 +113,7 @@ describe("makeQuitHoldHandler", () => { await harness.send(makeInput({})); await harness.holdFor(500); await harness.send(makeInput({ type: "keyUp" })); - expect(harness.notifications).toEqual(["down", "up"]); + expect(harness.notifications).toEqual([HOLD_DOWN, UP]); vi.advanceTimersByTime((QUIT_HOLD_DURATION_MS + QUIT_HOLD_RELEASE_GRACE_MS) * 2); expect(harness.quit).not.toHaveBeenCalled(); }); @@ -117,61 +122,179 @@ describe("makeQuitHoldHandler", () => { const harness = makeHarness(); await harness.send(makeInput({})); await harness.send(makeInput({ type: "keyUp", key: "Meta", meta: false })); - expect(harness.notifications).toEqual(["down", "up"]); + expect(harness.notifications).toEqual([HOLD_DOWN, UP]); vi.advanceTimersByTime((QUIT_HOLD_DURATION_MS + QUIT_HOLD_RELEASE_GRACE_MS) * 2); expect(harness.quit).not.toHaveBeenCalled(); }); - it("quits without showing a hint when hold-to-quit is disabled", async () => { - const harness = makeHarness({ enabled: false }); + it("quits without showing a hint in direct mode", async () => { + const harness = makeHarness({ mode: "direct" }); await harness.send(makeInput({})); expect(harness.quit).toHaveBeenCalledTimes(1); expect(harness.notifications).toEqual([]); }); - it("discards a stale isEnabled resolution from a superseded press", async () => { - // Press #1's isEnabled is still pending when the user releases and + it("honors direct mode when the key is released before its mode read settles", async () => { + let resolveMode: ((mode: QuitConfirmationMode) => void) | undefined; + const harness = makeHarness({ + getMode: () => + new Promise((resolve) => { + resolveMode = resolve; + }), + }); + await harness.send(makeInput({})); + await harness.send(makeInput({ type: "keyUp" })); + + resolveMode?.("direct"); + await Promise.resolve(); + await Promise.resolve(); + + expect(harness.quit).toHaveBeenCalledTimes(1); + expect(harness.notifications).toEqual([]); + }); + + it("does not arm hold mode after a released key's mode read settles", async () => { + let resolveMode: ((mode: QuitConfirmationMode) => void) | undefined; + const harness = makeHarness({ + getMode: () => + new Promise((resolve) => { + resolveMode = resolve; + }), + }); + await harness.send(makeInput({})); + await harness.send(makeInput({ type: "keyUp" })); + + resolveMode?.("hold"); + await Promise.resolve(); + await Promise.resolve(); + + expect(harness.quit).not.toHaveBeenCalled(); + expect(harness.notifications).toEqual([]); + }); + + it("honors a quick double press when both key releases beat their mode reads", async () => { + const resolvers: Array<(mode: QuitConfirmationMode) => void> = []; + const harness = makeHarness({ + getMode: () => new Promise((resolve) => resolvers.push(resolve)), + }); + await harness.send(makeInput({})); + await harness.send(makeInput({ type: "keyUp" })); + vi.advanceTimersByTime(QUIT_DOUBLE_PRESS_MS - 100); + await harness.send(makeInput({})); + await harness.send(makeInput({ type: "keyUp" })); + + resolvers[1]?.("double-click"); + await Promise.resolve(); + await Promise.resolve(); + + expect(harness.quit).toHaveBeenCalledTimes(1); + expect(harness.notifications).toEqual([]); + }); + + it("discards a stale mode resolution from a superseded press", async () => { + // Press #1's mode is still pending when the user releases and // presses again; its late resolution must not act for press #2. - const resolvers: Array<(enabled: boolean) => void> = []; + const resolvers: Array<(mode: QuitConfirmationMode) => void> = []; const harness = makeHarness({ - isEnabled: () => new Promise((resolve) => resolvers.push(resolve)), + getMode: () => new Promise((resolve) => resolvers.push(resolve)), }); await harness.send(makeInput({})); await harness.send(makeInput({ type: "keyUp" })); - // Outside the double-tap window, so the second press starts a new hold. - vi.advanceTimersByTime(QUIT_DOUBLE_TAP_MS + 100); + // Outside the double-press window, so the second press starts a new hold. + vi.advanceTimersByTime(QUIT_DOUBLE_PRESS_MS + 100); await harness.send(makeInput({})); expect(resolvers).toHaveLength(2); - // Press #1 resolves late with "disabled" — it must not quit press #2. - resolvers[0]?.(false); + // Press #1 resolves late with "direct". It must not quit press #2. + resolvers[0]?.("direct"); await Promise.resolve(); await Promise.resolve(); expect(harness.quit).not.toHaveBeenCalled(); - // Press #2 resolves enabled and completes a full hold. - resolvers[1]?.(true); + // Press #2 resolves to hold and completes the gesture. + resolvers[1]?.("hold"); await harness.holdFor(QUIT_HOLD_DURATION_MS + 200); await harness.send(makeInput({ type: "keyUp" })); expect(harness.quit).toHaveBeenCalledTimes(1); }); - it("quits on a quick double tap, even when the first release was never seen", async () => { - const harness = makeHarness(); + it("quits on a quick double press in double-click mode when the first release is unseen", async () => { + const harness = makeHarness({ mode: "double-click" }); await harness.send(makeInput({})); - vi.advanceTimersByTime(QUIT_DOUBLE_TAP_MS - 100); + vi.advanceTimersByTime(QUIT_DOUBLE_PRESS_MS - 100); await harness.send(makeInput({})); expect(harness.quit).toHaveBeenCalledTimes(1); + expect(harness.notifications).toEqual([DOUBLE_CLICK_DOWN, UP]); + }); + + it("keeps the double-press hint visible after key release until the window ends", async () => { + const harness = makeHarness({ mode: "double-click" }); + await harness.send(makeInput({})); + vi.advanceTimersByTime(100); + await harness.send(makeInput({ type: "keyUp" })); + expect(harness.notifications).toEqual([DOUBLE_CLICK_DOWN]); + + vi.advanceTimersByTime(QUIT_DOUBLE_PRESS_MS - 101); + expect(harness.notifications).toEqual([DOUBLE_CLICK_DOWN]); + vi.advanceTimersByTime(1); + expect(harness.notifications).toEqual([DOUBLE_CLICK_DOWN, UP]); }); - it("treats two slow taps as separate presses", async () => { + it("accepts a second full shortcut after the modifier is released and pressed again", async () => { + const harness = makeHarness({ mode: "double-click" }); + await harness.send(makeInput({})); + await harness.send(makeInput({ type: "keyUp" })); + await harness.send(makeInput({ type: "keyUp", key: "Meta", meta: false })); + vi.advanceTimersByTime(100); + + await harness.send(makeInput({ key: "Meta" })); + await harness.send(makeInput({})); + + expect(harness.quit).toHaveBeenCalledTimes(1); + expect(harness.notifications).toEqual([DOUBLE_CLICK_DOWN, UP]); + }); + + it("expires a delayed double-press hint from keydown rather than mode resolution", async () => { + let resolveMode: ((mode: QuitConfirmationMode) => void) | undefined; + const harness = makeHarness({ + getMode: () => + new Promise((resolve) => { + resolveMode = resolve; + }), + }); + await harness.send(makeInput({})); + vi.advanceTimersByTime(100); + await harness.send(makeInput({ type: "keyUp" })); + vi.advanceTimersByTime(100); + resolveMode?.("double-click"); + await Promise.resolve(); + await Promise.resolve(); + expect(harness.notifications).toEqual([DOUBLE_CLICK_DOWN]); + + vi.advanceTimersByTime(QUIT_DOUBLE_PRESS_MS - 201); + expect(harness.notifications).toEqual([DOUBLE_CLICK_DOWN]); + vi.advanceTimersByTime(1); + expect(harness.notifications).toEqual([DOUBLE_CLICK_DOWN, UP]); + }); + + it("treats two slow presses as separate attempts in double-click mode", async () => { + const harness = makeHarness({ mode: "double-click" }); + await harness.send(makeInput({})); + await harness.send(makeInput({ type: "keyUp" })); + vi.advanceTimersByTime(QUIT_DOUBLE_PRESS_MS + 100); + await harness.send(makeInput({})); + expect(harness.quit).not.toHaveBeenCalled(); + expect(harness.notifications).toEqual([DOUBLE_CLICK_DOWN, UP, DOUBLE_CLICK_DOWN]); + }); + + it("does not treat two quick presses as a quit in hold mode", async () => { const harness = makeHarness(); await harness.send(makeInput({})); await harness.send(makeInput({ type: "keyUp" })); - vi.advanceTimersByTime(QUIT_DOUBLE_TAP_MS + 100); + vi.advanceTimersByTime(QUIT_DOUBLE_PRESS_MS - 100); await harness.send(makeInput({})); expect(harness.quit).not.toHaveBeenCalled(); - expect(harness.notifications).toEqual(["down", "up", "down"]); + expect(harness.notifications).toEqual([HOLD_DOWN, UP, HOLD_DOWN]); }); it("cancels the hold when another key interrupts it", async () => { @@ -180,21 +303,20 @@ describe("makeQuitHoldHandler", () => { await harness.holdFor(500); // Shift pressed mid-hold breaks the gesture... await harness.send(makeInput({ shift: true })); - expect(harness.notifications).toEqual(["down", "up"]); + expect(harness.notifications).toEqual([HOLD_DOWN, UP]); // ...so later repeats past the threshold must not quit. await harness.holdFor(QUIT_HOLD_DURATION_MS); expect(harness.quit).not.toHaveBeenCalled(); }); - it("does not count an interrupted press toward a double tap", async () => { - const harness = makeHarness(); + it("does not count an interrupted press toward a double press", async () => { + const harness = makeHarness({ mode: "double-click" }); await harness.send(makeInput({})); await harness.send(makeInput({ shift: true })); - // A fresh press right after the interruption starts a new hold, not a - // double-tap quit. + // A fresh press right after the interruption starts a new attempt. await harness.send(makeInput({})); expect(harness.quit).not.toHaveBeenCalled(); - expect(harness.notifications).toEqual(["down", "up", "down"]); + expect(harness.notifications).toEqual([DOUBLE_CLICK_DOWN, UP, DOUBLE_CLICK_DOWN]); }); it("ignores other shortcuts", async () => { diff --git a/apps/desktop/src/window/QuitHold.ts b/apps/desktop/src/window/QuitHold.ts index 885770accfa2..5756de64d417 100644 --- a/apps/desktop/src/window/QuitHold.ts +++ b/apps/desktop/src/window/QuitHold.ts @@ -1,15 +1,12 @@ // @effect-diagnostics globalDate:off globalTimers:off -- Synchronous before-input-event handler; key events must be timed and the watchdog scheduled outside any Effect runtime. -// Chrome-style hold-to-quit. The quit accelerator is intercepted in -// before-input-event (which runs before the native menu accelerator), and the -// app only quits after the shortcut has been held for QUIT_HOLD_DURATION_MS -// and released. -// A quick tap just shows the renderer's "Hold to Quit" hint, and a second tap -// within QUIT_DOUBLE_TAP_MS quits immediately. Quitting from the application -// menu itself is untouched and quits immediately. +import type { QuitConfirmationMode, QuitShortcutHintEvent } from "@t3tools/contracts"; + +// The quit accelerator is intercepted in before-input-event, which runs +// before the native menu accelerator. Quitting from the application menu is +// untouched and always quits immediately. export const QUIT_HOLD_DURATION_MS = 1200; -// A second quick tap of the shortcut is the user insisting: quit immediately. -export const QUIT_DOUBLE_TAP_MS = 500; +export const QUIT_DOUBLE_PRESS_MS = 500; // "Still held" is proven by auto-repeat keydowns, not by the absence of a // release: macOS suppresses a letter keyUp while the command key is down, so a // tap release can go completely unseen and a release-based timer would quit @@ -18,8 +15,6 @@ export const QUIT_DOUBLE_TAP_MS = 500; // auto-repeat disabled fall back to the application menu Quit action. export const QUIT_HOLD_RELEASE_GRACE_MS = 600; -export type QuitHoldState = "down" | "up"; - export interface QuitHoldKeyInput { readonly type: string; readonly key: string; @@ -30,27 +25,29 @@ export interface QuitHoldKeyInput { readonly isAutoRepeat: boolean; } -export interface QuitHoldOptions { +export interface QuitShortcutOptions { readonly platform: NodeJS.Platform; - readonly isEnabled: () => Promise; - readonly notify: (state: QuitHoldState) => void; + readonly getMode: () => Promise; + readonly notify: (event: QuitShortcutHintEvent) => void; readonly quit: () => void; } -export function makeQuitHoldHandler( - options: QuitHoldOptions, +export function makeQuitShortcutHandler( + options: QuitShortcutOptions, ): (event: { preventDefault: () => void }, input: QuitHoldKeyInput) => void { const modifierKey = options.platform === "darwin" ? "meta" : "control"; let watchdog: NodeJS.Timeout | undefined; let holding = false; - // Set once isEnabled resolves true; auto-repeats may only complete the hold when armed. + let mode: QuitConfirmationMode | undefined; + let notified = false; + // Set once getMode resolves to hold; auto-repeats may only complete the hold when armed. let armed = false; let quitOnRelease = false; let heldSince = 0; let lastPressAt = 0; - // Incremented on every new press and every release/quit so a pending - // isEnabled() resolution from a superseded press cannot arm (or quit for) - // the current one. + // Incremented when a press is superseded or explicitly cancelled. A plain + // key release does not invalidate its pending mode read: direct mode and a + // completed second press must still be honored after that read settles. let generation = 0; const clearWatchdog = () => { @@ -60,19 +57,24 @@ export function makeQuitHoldHandler( } }; - const release = () => { - if (!holding) return; - const shouldNotify = armed || quitOnRelease; - generation += 1; + const release = (cancelPendingMode = true, keepDoublePressHint = false) => { + if (!holding && !notified) return; + const keepHint = keepDoublePressHint && mode === "double-click" && notified; + if (cancelPendingMode) generation += 1; holding = false; armed = false; quitOnRelease = false; + if (keepHint) return; + + mode = undefined; clearWatchdog(); - if (shouldNotify) options.notify("up"); + if (notified) { + notified = false; + options.notify({ state: "up" }); + } }; - // Dismisses any overlay first: if the quit is cancelled downstream the - // renderer must not be left with a stuck "Hold to Quit" hint. + // Dismisses any overlay first so a cancelled quit cannot leave a stale hint. const quitNow = () => { release(); options.quit(); @@ -83,11 +85,11 @@ export function makeQuitHoldHandler( if (input.type === "keyUp") { if (key === "q") { const shouldQuit = quitOnRelease; - release(); + release(false, true); if (shouldQuit) options.quit(); } else if (key === modifierKey) { if (!quitOnRelease) { - release(); + release(false, true); } else { watchdog = setTimeout(quitNow, QUIT_HOLD_RELEASE_GRACE_MS); } @@ -104,13 +106,17 @@ export function makeQuitHoldHandler( const modifierDown = options.platform === "darwin" ? input.meta : input.control; if (!modifierDown || input.alt || input.shift || key !== "q") { + // Re-pressing the platform modifier is the first half of a second full + // quit shortcut, so it must not cancel an active double-press window. + if (key === modifierKey && !input.alt && !input.shift) return; + // Any other key (or an extra modifier) pressed mid-hold breaks the // gesture; without this the hold timer keeps running through the // interruption and the next qualifying repeat would quit early. The - // interrupted press also stops counting toward a double tap — but only + // interrupted press also stops counting toward a double press, but only // here, not in release(), which runs mid-restart on an unseen-release // re-press and must not wipe that press's own tap timestamp. - if (holding && !input.isAutoRepeat) { + if ((holding || notified) && !input.isAutoRepeat) { lastPressAt = 0; release(); } @@ -120,7 +126,7 @@ export function makeQuitHoldHandler( event.preventDefault(); if (input.isAutoRepeat) { - if (armed && Date.now() - heldSince >= QUIT_HOLD_DURATION_MS) { + if (mode === "hold" && armed && Date.now() - heldSince >= QUIT_HOLD_DURATION_MS) { armed = false; quitOnRelease = true; clearWatchdog(); @@ -131,28 +137,51 @@ export function makeQuitHoldHandler( const now = Date.now(); const previousPressAt = lastPressAt; lastPressAt = now; - // A fresh keydown while "holding" means the key came back down after a - // release macOS never delivered — so both branches below see real taps. - if (previousPressAt !== 0 && now - previousPressAt <= QUIT_DOUBLE_TAP_MS) { - quitNow(); - return; - } - if (holding) release(); + // A fresh keydown supersedes the current physical hold or the hint kept + // alive after a detected release. + if (holding || notified) release(); generation += 1; const pressGeneration = generation; holding = true; heldSince = now; - void options.isEnabled().then( - (enabled) => { + void options.getMode().then( + (resolvedMode) => { if (generation !== pressGeneration) return; - if (!enabled) { - // Hold-to-quit disabled: a single press quits immediately. + if (resolvedMode === "direct") { quitNow(); return; } + if ( + resolvedMode === "double-click" && + previousPressAt !== 0 && + now - previousPressAt <= QUIT_DOUBLE_PRESS_MS + ) { + quitNow(); + return; + } + + if (resolvedMode === "double-click") { + const remainingMs = QUIT_DOUBLE_PRESS_MS - (Date.now() - now); + if (remainingMs <= 0) { + release(); + return; + } + mode = resolvedMode; + notified = true; + options.notify({ state: "down", mode: resolvedMode }); + watchdog = setTimeout(release, remainingMs); + return; + } + + // A hold cannot be armed after its physical press has ended. + if (!holding) return; + + mode = resolvedMode; + notified = true; + options.notify({ state: "down", mode: resolvedMode }); + armed = true; - options.notify("down"); // No auto-repeat by then means the key was released (possibly with a // suppressed keyUp) or repeat is disabled; either way, don't quit. watchdog = setTimeout(() => { diff --git a/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts b/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts index 895d246e3689..1c6b64464d18 100644 --- a/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts +++ b/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts @@ -1,5 +1,7 @@ +// @effect-diagnostics nodeBuiltinImport:off - the executed suite runs the generated install script through a real POSIX shell. import { describe, it } from "@effect/vitest"; -import { expect } from "vite-plus/test"; +import { afterAll, expect } from "vite-plus/test"; +import * as NodeChildProcess from "node:child_process"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; @@ -11,6 +13,9 @@ import { ChildProcessSpawner } from "effect/unstable/process"; import { buildWslNodeEnvPreamble, + buildWslRuntimeInstallScript, + buildWslRuntimeInvalidateScript, + buildWslRuntimePruneScript, DesktopWslDistroListError, formatMissingToolsReason, formatNodePtyProbeFailureReason, @@ -19,11 +24,62 @@ import { parseNodeVersion, parseResolvedPath, parseToolchainReport, + parseWslRuntimeRoot, probeWslDistros, + sanitizeWslRuntimeId, } from "./DesktopWslEnvironment.ts"; const encoder = new TextEncoder(); +// The install script only fails the way this file cares about when a real shell +// runs it, so find one that has the tools it needs: bash directly on Linux, and +// the WSL distro on a Windows dev box, where Git Bash ships no flock. Anywhere +// else the executed suite skips and the generated-text assertions stand alone. +const REQUIRED_SHELL_TOOLS = ["flock", "sha256sum", "tar", "mktemp"] as const; + +const posixShellRunner = (() => { + // Candidates rather than a platform switch: wsl.exe simply fails to spawn + // where it does not exist, which is the same answer as a shell missing flock. + const candidates = [ + { file: "bash", args: [] as ReadonlyArray }, + { file: "wsl.exe", args: ["-e", "bash"] as ReadonlyArray }, + ]; + const probe = [ + "[ -d /proc/1 ] || exit 1", + ...REQUIRED_SHELL_TOOLS.map((tool) => `command -v ${tool} >/dev/null || exit 1`), + ].join("\n"); + return ( + candidates.find((candidate) => { + const result = NodeChildProcess.spawnSync(candidate.file, [...candidate.args, "-c", probe], { + encoding: "utf8", + }); + return result.status === 0; + }) ?? null + ); +})(); + +const runShell = (script: string) => { + if (posixShellRunner === null) throw new Error("no POSIX shell runner available"); + // The install script arrives on stdin in production too, which is what lets + // its own /proc scan not match itself. + const result = NodeChildProcess.spawnSync( + posixShellRunner.file, + [...posixShellRunner.args, "-s"], + { input: script, encoding: "utf8" }, + ); + return { status: result.status, stdout: result.stdout ?? "", stderr: result.stderr ?? "" }; +}; + +const sh = (value: string) => `'${value.replaceAll("'", "'\\''")}'`; + +const readField = (stdout: string, field: string) => { + const line = stdout.split("\n").find((candidate) => candidate.startsWith(`${field}:`)); + if (line === undefined) throw new Error(`missing ${field} in fixture output: ${stdout}`); + return line.slice(field.length + 1).trim(); +}; + +const SERVER_ENTRY_SOURCE = 'console.log("t3code wsl runtime test server");'; + const makeDistroListSpawner = (result: { readonly stdout?: string; readonly exitCode?: number }) => ChildProcessSpawner.make(() => Effect.succeed( @@ -125,6 +181,572 @@ describe("buildWslNodeEnvPreamble", () => { }); }); +describe("WSL runtime cache", () => { + it("sanitizes cache ids before interpolating them into Linux paths", () => { + expect(sanitizeWslRuntimeId("1.2.3/x64; touch /tmp/nope")).toBe("1.2.3_x64__touch__tmp_nope"); + }); + + it("installs through a temporary directory and only reuses valid completed caches", () => { + const script = buildWslRuntimeInstallScript( + "/mnt/c/Program Files/T3 Code/wsl-runtime.tar.gz", + "1.2.3-x64", + "b".repeat(64), + ); + + expect(script).toContain('runtime_parent="$HOME/.t3/wsl-runtime"'); + expect(script).toContain(' [ -f "$ready_marker" ] &&'); + expect(script).toContain(' [ -f "$runtime_root/apps/server/dist/bin.mjs" ] &&'); + expect(script).toContain(' [ -f "$runtime_root/node_modules/node-pty/package.json" ] &&'); + expect(script).toContain(' node_pty_payload_present "$runtime_root"'); + expect(script).not.toContain("node_modules/effect/package.json"); + expect(script).toContain("if runtime_is_ready; then"); + expect(script).toContain("trap 'exit 1' HUP INT TERM"); + expect(script).toContain('exec 9> "$runtime_lock"'); + expect(script).toContain("flock -x 9"); + expect(script).not.toContain("runtime_lock_pid"); + expect(script).not.toContain("sleep 0.1"); + expect(script).not.toContain('rm -rf "$runtime_lock"'); + expect(script).toContain('mv -T "$runtime_root" "$runtime_stale"'); + expect(script).toContain('mktemp -d "$runtime_parent/.1.2.3-x64.tmp.XXXXXX"'); + expect(script).toContain( + "tar -xzf '/mnt/c/Program Files/T3 Code/wsl-runtime.tar.gz' -C \"$runtime_tmp\"", + ); + expect(script).toContain('test -f "$runtime_tmp/apps/server/dist/bin.mjs"'); + expect(script).toContain('test -f "$runtime_tmp/node_modules/node-pty/package.json"'); + expect(script).toContain('mv -T "$runtime_tmp" "$runtime_root"'); + expect(script).not.toContain('rm -rf "$runtime_root"'); + + const lockAcquired = script.indexOf("flock -x 9"); + const readinessAfterLock = script.indexOf("if runtime_is_ready; then", lockAcquired + 1); + const existingRuntimeMoved = script.indexOf('mv -T "$runtime_root" "$runtime_stale"'); + expect(lockAcquired).toBeGreaterThan(-1); + expect(readinessAfterLock).toBeGreaterThan(lockAcquired); + expect(existingRuntimeMoved).toBeGreaterThan(readinessAfterLock); + }); + + it("verifies the archive digest before extracting, and only on a cache miss", () => { + const script = buildWslRuntimeInstallScript( + "/mnt/c/Program Files/T3 Code/wsl-runtime.tar.gz", + "1.2.3-x64", + "b".repeat(64), + ); + + const expected = "b".repeat(64); + expect(script).toContain( + "archive_sha=$(sha256sum '/mnt/c/Program Files/T3 Code/wsl-runtime.tar.gz' | cut -d ' ' -f 1)", + ); + expect(script).toContain(`if [ "$archive_sha" != '${expected}' ]; then`); + + // A warm cache exits before the hash, so reuse never pays for it, and the + // mismatch check runs before anything mutates the cache. + const readyShortCircuit = script.indexOf("if runtime_is_ready; then"); + const digestChecked = script.indexOf("archive_sha=$(sha256sum"); + const existingRuntimeMoved = script.indexOf('mv -T "$runtime_root" "$runtime_stale"'); + const extracted = script.indexOf("tar -xzf"); + expect(digestChecked).toBeGreaterThan(readyShortCircuit); + expect(existingRuntimeMoved).toBeGreaterThan(digestChecked); + expect(extracted).toBeGreaterThan(digestChecked); + }); + + // Invalidation revokes the ready marker without stopping the backend that + // failed the probe, so the next install can find an unready tree that a live + // process is still running out of. Deleting it there unlinks node_modules + // under that process; the pruner already refuses to touch in-use caches, and + // the install path has to refuse too. + it("moves an in-use runtime aside instead of deleting it under a live backend", () => { + const script = buildWslRuntimeInstallScript( + "/mnt/c/Program Files/T3 Code/wsl-runtime.tar.gz", + "sha256-" + "c".repeat(64), + "b".repeat(64), + ); + + expect(script).toContain('grep -qF -- "$1/" /proc/[0-9]*/cmdline 2>/dev/null'); + // No /proc means no way to tell, and guessing wrong costs a backend its + // runtime, so an unknowable answer has to count as in use. + expect(script).toContain(" [ -d /proc/1 ] || return 0"); + expect(script).toContain(' if runtime_in_use "$runtime_root"; then'); + + // A process's cmdline keeps the pre-rename path, so the question is only + // answerable before the move. + const inUseChecked = script.indexOf('if runtime_in_use "$runtime_root"; then'); + const moved = script.indexOf('mv -T "$runtime_root" "$runtime_stale"'); + expect(inUseChecked).toBeGreaterThan(-1); + expect(inUseChecked).toBeLessThan(moved); + + // In use: keep the tree and restart the sweep's clock, because renaming + // preserves the directory's mtime and a long-installed tree would otherwise + // already be past the age gate. Idle: delete it now, as before. + const kept = script.indexOf('touch "$runtime_stale"'); + const deleted = script.indexOf('rm -rf "$runtime_stale"'); + expect(kept).toBeGreaterThan(moved); + expect(deleted).toBeGreaterThan(kept); + }); + + it("treats a runtime whose native payload went missing as a cache miss", () => { + const script = buildWslRuntimeInstallScript( + "/mnt/c/Program Files/T3 Code/wsl-runtime.tar.gz", + "1.2.3-x64", + "b".repeat(64), + ); + + // A glob, not a mapped `uname -m`: this is a presence check, and the later + // native probe is what judges arch and loadability. + expect(script).toContain( + ' for candidate in "$1"/node_modules/node-pty/prebuilds/linux-*/pty.node; do', + ); + // The marker the probe reads must sit beside the binary, or the runtime is + // just as unusable as one missing pty.node outright. + expect(script).toContain(' [ -f "${candidate%/*}/t3code-wsl-node-pty.json" ] || continue'); + + // Readiness gates the short-circuit, so a cache missing the payload + // reinstalls from the archive instead of being reused forever. + const payloadCheckDefined = script.indexOf("node_pty_payload_present() {"); + const readinessDefined = script.indexOf("runtime_is_ready() {"); + const readyShortCircuit = script.indexOf("if runtime_is_ready; then"); + expect(payloadCheckDefined).toBeGreaterThan(-1); + expect(payloadCheckDefined).toBeLessThan(readinessDefined); + expect(readinessDefined).toBeLessThan(readyShortCircuit); + }); + + // A truncated or half-written bin.mjs passes every presence check the cache + // had: the file exists, node-pty still loads, and launch then picks a server + // that exits before it becomes ready — forever, because nothing ever + // reinstalls. The digest the install records is what turns that into a miss. + it("re-hashes the server entry against the digest the install recorded", () => { + const script = buildWslRuntimeInstallScript( + "/mnt/c/Program Files/T3 Code/wsl-runtime.tar.gz", + "1.2.3-x64", + "b".repeat(64), + ); + + expect(script).toContain( + ` sha256sum "$1/apps/server/dist/bin.mjs" 2>/dev/null | cut -d ' ' -f 1`, + ); + expect(script).toContain( + ' [ "$recorded_entry_digest" = "$(runtime_server_entry_digest "$runtime_root")" ]', + ); + // A runtime installed before the marker carried a digest reads as empty, + // which has to be a miss rather than a pass. + expect(script).toContain(' [ -n "$recorded_entry_digest" ] &&'); + expect(script).toContain( + `printf '%s\\n' "$installed_entry_digest" > "$runtime_tmp/.t3code-wsl-runtime-ready"`, + ); + + // The digest is recorded after extraction and before promotion. + const extracted = script.indexOf("tar -xzf"); + const digestRecorded = script.indexOf( + 'installed_entry_digest=$(runtime_server_entry_digest "$runtime_tmp")', + ); + const markerWritten = script.indexOf('> "$runtime_tmp/.t3code-wsl-runtime-ready"'); + const promoted = script.indexOf('mv -T "$runtime_tmp" "$runtime_root"'); + expect(digestRecorded).toBeGreaterThan(extracted); + expect(markerWritten).toBeGreaterThan(digestRecorded); + expect(promoted).toBeGreaterThan(markerWritten); + }); + + it("refuses to mark an archive without a native payload as ready", () => { + const script = buildWslRuntimeInstallScript( + "/mnt/c/Program Files/T3 Code/wsl-runtime.tar.gz", + "1.2.3-x64", + "b".repeat(64), + ); + + expect(script).toContain('if ! node_pty_payload_present "$runtime_tmp"; then'); + + // The extracted tree is rejected before the ready marker is written, so a + // defective archive falls back to the mounted tree instead of caching. + const payloadValidated = script.indexOf('node_pty_payload_present "$runtime_tmp"'); + const markerWritten = script.indexOf('> "$runtime_tmp/.t3code-wsl-runtime-ready"'); + const promoted = script.indexOf('mv -T "$runtime_tmp" "$runtime_root"'); + expect(payloadValidated).toBeGreaterThan(-1); + expect(markerWritten).toBeGreaterThan(payloadValidated); + expect(promoted).toBeGreaterThan(payloadValidated); + }); + + it("parses only absolute Linux runtime paths", () => { + expect(parseWslRuntimeRoot("runtimeRoot:/home/josh/.t3/wsl-runtime/1.2.3-x64\n")).toBe( + "/home/josh/.t3/wsl-runtime/1.2.3-x64", + ); + expect(parseWslRuntimeRoot("runtimeRoot:relative/path\n")).toBeNull(); + expect(parseWslRuntimeRoot("noise\n")).toBeNull(); + }); + + it("prunes completed runtimes except the current and newest previous cache", () => { + const script = buildWslRuntimePruneScript("1.2.3/x64"); + + expect(script).toContain('current_runtime="$runtime_parent/1.2.3_x64"'); + expect(script).toContain('[ "$candidate" -nt "$previous_runtime" ]'); + expect(script).toContain('[ "$candidate" != "$current_runtime" ] || continue'); + expect(script).toContain('[ "$candidate" != "$previous_runtime" ] || continue'); + expect(script).toContain('[ -f "$candidate/.t3code-wsl-runtime-ready" ] || continue'); + expect(script).toContain('rm -rf -- "$candidate"'); + }); + + it("never deletes a runtime another backend is running from", () => { + const script = buildWslRuntimePruneScript("1.2.3/x64"); + + // The running backend's argv holds `/apps/server/dist/bin.mjs`, so + // the process itself is the lease and exiting releases it. Nothing has to be + // registered up front, which is what makes this cover backends already + // running from an older version that knows nothing about pruning. + expect(script).toContain(' grep -qF -- "$1/" /proc/[0-9]*/cmdline 2>/dev/null'); + expect(script).toContain(' ! runtime_in_use "$candidate" || continue'); + + // Without visible processes the retention rules cannot tell a live cache + // from an abandoned one, so the sweep is skipped rather than guessed at. + expect(script).toContain("[ -d /proc/1 ] || exit 0"); + + // The guard has to gate the delete, not just exist. + const inUseChecked = script.indexOf('! runtime_in_use "$candidate"'); + const removed = script.indexOf('rm -rf -- "$candidate"'); + expect(inUseChecked).toBeGreaterThan(-1); + expect(removed).toBeGreaterThan(inUseChecked); + }); + + it("sweeps orphaned install scratch directories the ready-marker loops cannot see", () => { + const script = buildWslRuntimePruneScript("1.2.3/x64"); + + // Dot-prefixed, so `"$runtime_parent"/*` never matches them, and they carry + // no ready marker either; without this pass a killed install leaks forever. + expect(script).toContain( + 'for scratch in "$runtime_parent"/.*.tmp.* "$runtime_parent"/.*.stale.*; do', + ); + // Age guard: a scratch directory younger than this belongs to a live install. + expect(script).toContain('find "$scratch" -maxdepth 0 -mmin +120'); + }); + + it("invalidates a cache by dropping its ready marker, not the tree", () => { + const script = buildWslRuntimeInvalidateScript("1.2.3/x64"); + + // Readiness is a presence check, so a tree whose pty.node is present but + // unloadable stays ready forever unless the probe can revoke the marker. + expect(script).toContain('rm -f "$HOME/.t3/wsl-runtime/1.2.3_x64/.t3code-wsl-runtime-ready"'); + // Deleting the tree here would pull it out from under any backend still + // running from it; the next install moves an unready root aside instead. + expect(script).not.toContain("rm -rf"); + }); +}); + +// Reading the generated script proves what it says, not what it does. A cache +// whose bin.mjs was truncated satisfied every assertion above and still got +// reused, so these run the real script against a real archive in a throwaway +// HOME and check the outcome. +describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed)", () => { + const fixtures: Array = []; + + afterAll(() => { + for (const work of fixtures) runShell(`set -eu\nrm -rf ${sh(work)}`); + fixtures.length = 0; + }); + + const createFixture = () => { + const result = runShell( + [ + "set -eu", + "work=$(mktemp -d)", + 'stage="$work/stage"', + 'mkdir -p "$stage/apps/server/dist" "$stage/node_modules/node-pty/prebuilds/linux-x64" "$work/home"', + `printf '%s' ${sh(SERVER_ENTRY_SOURCE)} > "$stage/apps/server/dist/bin.mjs"`, + `printf '%s' '{"name":"node-pty","version":"0.0.0-test"}' > "$stage/node_modules/node-pty/package.json"`, + `printf '%s' 'pty-native-payload' > "$stage/node_modules/node-pty/prebuilds/linux-x64/pty.node"`, + `printf '%s' '{"arch":"x64"}' > "$stage/node_modules/node-pty/prebuilds/linux-x64/t3code-wsl-node-pty.json"`, + `tar -czf "$work/wsl-runtime.tar.gz" -C "$stage" apps/server/dist node_modules`, + `printf 'work:%s\\n' "$work"`, + `printf 'archiveSha:%s\\n' "$(sha256sum "$work/wsl-runtime.tar.gz" | cut -d ' ' -f 1)"`, + ].join("\n"), + ); + expect(result.status, result.stderr).toBe(0); + + const work = readField(result.stdout, "work"); + fixtures.push(work); + const archivePath = `${work}/wsl-runtime.tar.gz`; + const archiveSha = readField(result.stdout, "archiveSha"); + const runtimeId = `sha256-${archiveSha}`; + // The script reads $HOME, and WSL does not inherit the parent process's + // environment, so the home override rides in the script itself. + const installScript = (archive = archivePath, sha = archiveSha) => + [ + `HOME=${sh(`${work}/home`)}`, + "export HOME", + buildWslRuntimeInstallScript(archive, runtimeId, sha), + ].join("\n"); + return { + work, + archivePath, + archiveSha, + runtimeId, + runtimeParent: `${work}/home/.t3/wsl-runtime`, + runtimeRoot: `${work}/home/.t3/wsl-runtime/${runtimeId}`, + serverEntry: `${work}/home/.t3/wsl-runtime/${runtimeId}/apps/server/dist/bin.mjs`, + installScript, + install: (archive?: string, sha?: string) => runShell(installScript(archive, sha)), + }; + }; + + it("reuses a warm cache without touching the archive", () => { + const fixture = createFixture(); + expect(fixture.install().status).toBe(0); + // Deleting the archive is how the test tells reuse apart from a silent + // reinstall: only the warm path can succeed without it. + expect(runShell(`set -eu\nrm ${sh(fixture.archivePath)}`).status).toBe(0); + + const warm = fixture.install(); + + expect(warm.status, warm.stderr).toBe(0); + expect(parseWslRuntimeRoot(warm.stdout)).toBe(fixture.runtimeRoot); + }); + + it("reinstalls a cache whose server entry was truncated", () => { + const fixture = createFixture(); + expect(fixture.install().status).toBe(0); + expect(runShell(`set -eu\n: > ${sh(fixture.serverEntry)}`).status).toBe(0); + + const repaired = fixture.install(); + + expect(repaired.status, repaired.stderr).toBe(0); + expect(parseWslRuntimeRoot(repaired.stdout)).toBe(fixture.runtimeRoot); + const restored = runShell(`set -eu\ncat ${sh(fixture.serverEntry)}`); + expect(restored.stdout).toBe(SERVER_ENTRY_SOURCE); + }); + + it("falls back instead of launching a corrupted cache it cannot reinstall", () => { + const fixture = createFixture(); + expect(fixture.install().status).toBe(0); + expect(runShell(`set -eu\n: > ${sh(fixture.serverEntry)}`).status).toBe(0); + expect(runShell(`set -eu\nrm ${sh(fixture.archivePath)}`).status).toBe(0); + + const broken = fixture.install(); + + // Non-zero with no runtimeRoot is what sends the backend to the mounted + // server tree. Exiting 0 here is the bug: launch would pick the zero-byte + // server, fail to become ready, and do it again on every restart. + expect(broken.status).not.toBe(0); + expect(parseWslRuntimeRoot(broken.stdout)).toBeNull(); + }); + + it("extracts once when two installs race for the same cache", () => { + const fixture = createFixture(); + // A tar shim counts extractions and holds the critical section open long + // enough that the second install is certain to arrive while the first is + // still inside it. One extraction is the answer either way the runs + // interleave: whoever waits for the lock re-checks readiness before + // spending an extract, so a broken lock shows up as two. + const raced = runShell( + [ + "set -eu", + `work=${sh(fixture.work)}`, + 'mkdir -p "$work/bin"', + "real_tar=$(command -v tar)", + `printf '#!/bin/sh\\nprintf x >> "%s/tar-calls"\\nsleep 1\\nexec %s "$@"\\n' "$work" "$real_tar" > "$work/bin/tar"`, + 'chmod +x "$work/bin/tar"', + ': > "$work/tar-calls"', + 'PATH="$work/bin:$PATH"', + "export PATH", + `cat > "$work/install.sh" <<'T3CODE_INSTALL_SCRIPT'`, + fixture.installScript(), + "T3CODE_INSTALL_SCRIPT", + // Both racers run the same file, and neither file path contains the + // runtime root, so the script's own /proc scan cannot see them. + 'sh "$work/install.sh" > "$work/first.out" 2>&1 &', + "first=$!", + 'sh "$work/install.sh" > "$work/second.out" 2>&1 &', + "second=$!", + "if wait $first; then first_status=0; else first_status=$?; fi", + "if wait $second; then second_status=0; else second_status=$?; fi", + `printf 'firstStatus:%s\\n' "$first_status"`, + `printf 'secondStatus:%s\\n' "$second_status"`, + `printf 'extractions:%s\\n' "$(wc -c < "$work/tar-calls" | tr -d ' ')"`, + `printf 'firstRoot:%s\\n' "$(sed -n 's/^runtimeRoot://p' "$work/first.out")"`, + `printf 'secondRoot:%s\\n' "$(sed -n 's/^runtimeRoot://p' "$work/second.out")"`, + ].join("\n"), + ); + + expect(raced.status, raced.stderr).toBe(0); + expect(readField(raced.stdout, "firstStatus")).toBe("0"); + expect(readField(raced.stdout, "secondStatus")).toBe("0"); + expect(readField(raced.stdout, "extractions")).toBe("1"); + expect(readField(raced.stdout, "firstRoot")).toBe(fixture.runtimeRoot); + expect(readField(raced.stdout, "secondRoot")).toBe(fixture.runtimeRoot); + }); + + it("leaves no half-built cache when extraction fails", () => { + const fixture = createFixture(); + // Truncating the archive and re-recording its digest gets the install past + // the digest gate and into a tar that dies mid-stream, which is what a full + // disk or an interrupted write looks like from inside the distro. + const truncated = runShell( + [ + "set -eu", + `work=${sh(fixture.work)}`, + 'size=$(wc -c < "$work/wsl-runtime.tar.gz")', + 'head -c $((size / 2)) "$work/wsl-runtime.tar.gz" > "$work/truncated.tar.gz"', + `printf 'sha:%s\\n' "$(sha256sum "$work/truncated.tar.gz" | cut -d ' ' -f 1)"`, + ].join("\n"), + ); + expect(truncated.status, truncated.stderr).toBe(0); + + const failed = fixture.install( + `${fixture.work}/truncated.tar.gz`, + readField(truncated.stdout, "sha"), + ); + + expect(failed.status).not.toBe(0); + expect(parseWslRuntimeRoot(failed.stdout)).toBeNull(); + // A partial extract that survived under the cache name would be promoted by + // the next launch's readiness check; scratch that survived would sit there + // until the pruner's age sweep. Neither is left behind. Only directories + // are counted: the empty flock file stays on purpose, which is what keeps + // the lock from carrying stale state across a killed install. + const leftovers = runShell( + `set -eu\nfind ${sh(fixture.runtimeParent)} -mindepth 1 -maxdepth 1 -type d`, + ); + expect(leftovers.stdout.trim()).toBe(""); + }); + + // The archive and the identity recorded beside it can diverge — a partial + // download, or a rebuilt archive dropped next to an older sidecar. Either + // gate firing means the bytes never reach the cache under a name that claims + // to describe something else. + it("refuses an archive whose bytes do not match the digest recorded for it", () => { + const fixture = createFixture(); + + const refused = fixture.install(fixture.archivePath, "e".repeat(64)); + + expect(refused.status).not.toBe(0); + expect(refused.stderr).toContain("does not match its recorded SHA-256"); + expect(parseWslRuntimeRoot(refused.stdout)).toBeNull(); + const leftovers = runShell( + `set -eu\nfind ${sh(fixture.runtimeParent)} -mindepth 1 -maxdepth 1 -type d`, + ); + expect(leftovers.stdout.trim()).toBe(""); + }); + + it("leases a warm cache across the prepare-to-spawn handoff", () => { + const fixture = createFixture(); + expect(fixture.install().status).toBe(0); + const selected = runShell( + [ + "set -eu", + `runtime_parent=${sh(fixture.runtimeParent)}`, + 'mkdir -p "$runtime_parent/sha256-current" "$runtime_parent/sha256-previous"', + 'printf ready > "$runtime_parent/sha256-current/.t3code-wsl-runtime-ready"', + 'printf ready > "$runtime_parent/sha256-previous/.t3code-wsl-runtime-ready"', + `touch -d "10 minutes ago" ${sh(fixture.runtimeRoot)}`, + 'touch -d "1 minute ago" "$runtime_parent/sha256-previous"', + `cat > ${sh(`${fixture.work}/select.sh`)} <<'T3CODE_SELECT_SCRIPT'`, + fixture.installScript(), + "T3CODE_SELECT_SCRIPT", + `sh ${sh(`${fixture.work}/select.sh`)}`, + `HOME=${sh(`${fixture.work}/home`)}`, + "export HOME", + buildWslRuntimePruneScript("sha256-current"), + `test -d ${sh(fixture.runtimeRoot)}`, + ].join("\n"), + ); + + expect(selected.status, `${selected.stdout}\n${selected.stderr}`).toBe(0); + }); + + it("prunes a selected cache after its prepare-to-spawn grace period expires", () => { + const fixture = createFixture(); + expect(fixture.install().status).toBe(0); + const result = runShell( + [ + "set -eu", + `runtime_parent=${sh(fixture.runtimeParent)}`, + 'mkdir -p "$runtime_parent/sha256-current" "$runtime_parent/sha256-previous"', + 'printf ready > "$runtime_parent/sha256-current/.t3code-wsl-runtime-ready"', + 'printf ready > "$runtime_parent/sha256-previous/.t3code-wsl-runtime-ready"', + `touch -d "10 minutes ago" ${sh(fixture.runtimeRoot)}`, + `touch -d "10 minutes ago" ${sh(`${fixture.runtimeRoot}/.t3code-wsl-runtime-selected`)}`, + 'touch -d "1 minute ago" "$runtime_parent/sha256-previous"', + `HOME=${sh(`${fixture.work}/home`)}`, + "export HOME", + buildWslRuntimePruneScript("sha256-current"), + `test ! -e ${sh(fixture.runtimeRoot)}`, + ].join("\n"), + ); + + expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0); + }); + + it("removes an aged stale tree after replacing an active unready cache", () => { + const fixture = createFixture(); + expect(fixture.install().status).toBe(0); + const result = runShell( + [ + "set -eu", + `runtime_root=${sh(fixture.runtimeRoot)}`, + `runtime_parent=${sh(fixture.runtimeParent)}`, + 'rm "$runtime_root/.t3code-wsl-runtime-ready"', + 'sh -c "sleep 30" "$runtime_root/apps/server/dist/bin.mjs" >/dev/null 2>&1 &', + "active_pid=$!", + "sleep 0.1", + fixture.installScript(), + 'stale=$(find "$runtime_parent" -maxdepth 1 -type d -name ".sha256-*.stale.*" -print -quit)', + 'test -n "$stale"', + 'touch -d "180 minutes ago" "$stale"', + `HOME=${sh(`${fixture.work}/home`)}`, + "export HOME", + buildWslRuntimePruneScript(fixture.runtimeId), + 'test ! -e "$stale"', + "kill $active_pid", + "wait $active_pid 2>/dev/null || true", + ].join("\n"), + ); + + expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0); + }); + + it("prunes old and markerless caches without touching retained, active, locked, or unrelated roots", () => { + const result = runShell( + [ + "set -eu", + "work=$(mktemp -d)", + 'home="$work/home"', + 'runtime_parent="$home/.t3/wsl-runtime"', + 'mkdir -p "$runtime_parent"', + 'make_ready() { mkdir -p "$runtime_parent/$1/apps/server/dist"; printf ready > "$runtime_parent/$1/.t3code-wsl-runtime-ready"; }', + "make_ready sha256-current", + "make_ready sha256-previous", + "make_ready sha256-active", + "make_ready sha256-old", + "make_ready sha256-locked", + 'mkdir -p "$runtime_parent/sha256-markerless" "$runtime_parent/versions"', + 'touch -d "1 minute ago" "$runtime_parent/sha256-previous"', + 'touch -d "4 minutes ago" "$runtime_parent/sha256-active"', + 'touch -d "3 minutes ago" "$runtime_parent/sha256-old"', + 'touch -d "2 minutes ago" "$runtime_parent/sha256-locked"', + 'sh -c "sleep 30" "$runtime_parent/sha256-active/apps/server/dist/bin.mjs" >/dev/null 2>&1 &', + "active_pid=$!", + "(", + ' exec 9> "$runtime_parent/.sha256-locked.install.lock"', + " flock -x 9", + " sleep 30", + ") >/dev/null 2>&1 &", + "lock_pid=$!", + "sleep 0.1", + `HOME="$home"`, + "export HOME", + buildWslRuntimePruneScript("sha256-current"), + 'test -d "$runtime_parent/sha256-current"', + 'test -d "$runtime_parent/sha256-previous"', + 'test -d "$runtime_parent/sha256-active"', + 'test -d "$runtime_parent/sha256-locked"', + 'test -d "$runtime_parent/versions"', + 'test ! -e "$runtime_parent/sha256-old"', + 'test ! -e "$runtime_parent/sha256-markerless"', + "kill $active_pid $lock_pid", + "wait $active_pid 2>/dev/null || true", + "wait $lock_pid 2>/dev/null || true", + 'rm -rf "$work"', + ].join("\n"), + ); + + expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0); + }); +}); + describe("parseToolchainReport", () => { it("returns no missing tools and no node version on empty output", () => { expect(parseToolchainReport("")).toEqual({ missingTools: [], nodeVersion: null }); diff --git a/apps/desktop/src/wsl/DesktopWslEnvironment.ts b/apps/desktop/src/wsl/DesktopWslEnvironment.ts index 164117727eaa..b0c9f5ffe44b 100644 --- a/apps/desktop/src/wsl/DesktopWslEnvironment.ts +++ b/apps/desktop/src/wsl/DesktopWslEnvironment.ts @@ -22,6 +22,9 @@ const WSLPATH_TIMEOUT = Duration.seconds(10); const PROBE_TIMEOUT = Duration.seconds(10); const TOOLCHAIN_TIMEOUT = Duration.seconds(10); const BUILD_TIMEOUT = Duration.minutes(5); +const RUNTIME_INSTALL_TIMEOUT = Duration.minutes(2); +const RUNTIME_PRUNE_TIMEOUT = Duration.seconds(30); +const RUNTIME_INVALIDATE_TIMEOUT = Duration.seconds(15); const USER_HOME_TIMEOUT = Duration.seconds(5); const TOOLCHAIN_TRANSPORT_RETRY_LIMIT = 12; const BUILD_TRANSPORT_RETRY_LIMIT = 2; @@ -31,6 +34,25 @@ export interface EnsureWslNodePtyOptions { readonly nodeEngineRange?: string | null; } +// The packaged WSL runtime archive plus the SHA-256 identity the build recorded +// for it. The cache key derives from the same digest, and installation verifies +// the bytes before promoting the extracted tree. +export interface WslRuntimeArchive { + readonly windowsPath: string; + readonly runtimeId: string; + readonly sha256: string; +} + +export type PrepareWslRuntimeResult = + | { + readonly ok: true; + readonly linuxAppRoot: string; + } + | { + readonly ok: false; + readonly reason: string; + }; + export type EnsureWslNodePtyResult = | { readonly ok: true; @@ -79,9 +101,16 @@ export class DesktopWslEnvironment extends Context.Service< // (the backend can be listening for 30+ seconds before wslhost starts // forwarding 127.0.0.1:port to WSL-side localhost). readonly getDistroIp: (distro: string | null) => Effect.Effect>; + readonly prepareRuntime: ( + distro: string | null, + archive: WslRuntimeArchive, + ) => Effect.Effect; + readonly pruneRuntimes: (distro: string | null, runtimeId: string) => Effect.Effect; + // Marks a staged runtime as unusable so the next launch reinstalls it. + readonly invalidateRuntime: (distro: string | null, runtimeId: string) => Effect.Effect; readonly ensureNodePty: ( distro: string | null, - windowsRepoRoot: string, + linuxAppRoot: string, options?: EnsureWslNodePtyOptions, ) => Effect.Effect; } @@ -149,18 +178,28 @@ const runWslShell = ( distro: string | null, bashScript: string, timeout: Duration.Duration, - options: EnsureWslNodePtyOptions = {}, + options: { + readonly nodeEngineRange?: string | null; + readonly resolveNode?: boolean; + } = {}, ): Effect.Effect => { const spawner = ChildProcessSpawner.ChildProcessSpawner; - // -l picks up profile-managed PATH; the shared resolver covers supported - // version managers that non-interactive login shells can miss. -s so bash - // reads the script from stdin. + // Node probes use a login bash so profile-managed PATH entries and supported + // version managers are available. Runtime installation needs only POSIX tools, + // so it skips profile loading and runs sh directly. + const resolveNode = options.resolveNode !== false; const command = ChildProcess.make( "wsl.exe", - [...buildDistroArgs(distro), "--", "bash", "-l", "-s"], + resolveNode + ? [...buildDistroArgs(distro), "--", "bash", "-l", "-s"] + : [...buildDistroArgs(distro), "--exec", "sh", "-s"], { stdin: Stream.encodeText( - Stream.make(`${buildWslNodeEnvPreamble(options.nodeEngineRange)}${bashScript}`), + Stream.make( + resolveNode + ? `${buildWslNodeEnvPreamble(options.nodeEngineRange)}${bashScript}` + : bashScript, + ), ), stdout: "pipe", stderr: "pipe", @@ -216,6 +255,240 @@ const runWslShell = ( const shellQuote = (value: string): string => `'${value.replaceAll("'", "'\\''")}'`; +// Holds the sha256 of the runtime's server entry, written when the install +// promotes a verified tree. Presence alone only says an install once finished +// here; the digest is what lets a later launch prove the entry still is what +// that install wrote. +const WSL_RUNTIME_READY_MARKER = ".t3code-wsl-runtime-ready"; +const WSL_RUNTIME_SELECTED_MARKER = ".t3code-wsl-runtime-selected"; +const WSL_RUNTIME_SELECTION_GRACE_MINUTES = 5; + +export const sanitizeWslRuntimeId = (value: string): string => + value.replace(/[^A-Za-z0-9._-]/g, "_"); + +// `archiveSha256` is the digest the build recorded alongside the archive. The +// install verifies the bytes before extracting, so an archive can never be +// promoted under an identity that does not describe it. +export const buildWslRuntimeInstallScript = ( + linuxArchivePath: string, + runtimeId: string, + archiveSha256: string, +): string => { + const safeRuntimeId = sanitizeWslRuntimeId(runtimeId); + return [ + "set -eu", + 'runtime_parent="$HOME/.t3/wsl-runtime"', + `runtime_root="$runtime_parent/${safeRuntimeId}"`, + `ready_marker="$runtime_root/${WSL_RUNTIME_READY_MARKER}"`, + // The native payload is the part of the tree the WSL backend actually + // dlopens, and the only part a user can plausibly break by hand. Checking + // node-pty's package.json alone let a runtime whose pty.node had gone + // missing stay cache-ready forever: every launch reused it and then failed + // the native probe, with no reinstall and no fallback. Match on the glob + // rather than a mapped `uname -m` so this stays a presence check; the probe + // is what decides whether the binary is the right arch and loadable. + "node_pty_payload_present() {", + ' for candidate in "$1"/node_modules/node-pty/prebuilds/linux-*/pty.node; do', + ' [ -f "$candidate" ] || continue', + ' [ -f "${candidate%/*}/t3code-wsl-node-pty.json" ] || continue', + " return 0", + " done", + " return 1", + "}", + // Hashing the server entry is the only check that can tell a working cache + // from one whose bin.mjs was truncated or half-written: the file is still + // there, the native probe still passes, and launch then picks a server that + // exits before it can become ready, on every restart. Hashing the ~7MB + // entry measures in single-digit milliseconds inside the distro, once per + // launch, against a cold reinstall of a few hundred megabytes. + "runtime_server_entry_digest() {", + ` sha256sum "$1/apps/server/dist/bin.mjs" 2>/dev/null | cut -d ' ' -f 1`, + "}", + "runtime_is_ready() {", + ' [ -f "$ready_marker" ] &&', + ' [ -f "$runtime_root/apps/server/dist/bin.mjs" ] &&', + ' [ -f "$runtime_root/node_modules/node-pty/package.json" ] &&', + ' node_pty_payload_present "$runtime_root" &&', + // An empty or unreadable marker is a miss, not a pass: that is what a + // runtime installed before the marker carried a digest looks like, and one + // reinstall is the cheapest way to make it verifiable from then on. + ` recorded_entry_digest=$(tr -d '[:space:]' < "$ready_marker" 2>/dev/null) &&`, + ' [ -n "$recorded_entry_digest" ] &&', + ' [ "$recorded_entry_digest" = "$(runtime_server_entry_digest "$runtime_root")" ]', + "}", + 'mkdir -p "$runtime_parent"', + `runtime_lock="$runtime_parent/.${safeRuntimeId}.install.lock"`, + "trap 'exit 1' HUP INT TERM", + 'exec 9> "$runtime_lock"', + "flock -x 9", + "if runtime_is_ready; then", + ` touch "$runtime_root/${WSL_RUNTIME_SELECTED_MARKER}"`, + ` printf 'runtimeRoot:%s\\n' "$runtime_root"`, + " exit 0", + "fi", + // Hash only on a cache miss: a warm launch already exited above, and a cold + // install is about to read the whole archive through tar anyway. `set -eu` + // turns a distro without sha256sum into an install failure, which falls back + // to the mounted server tree rather than trusting unverified bytes. + `archive_sha=$(sha256sum ${shellQuote(linuxArchivePath)} | cut -d ' ' -f 1)`, + `if [ "$archive_sha" != ${shellQuote(archiveSha256)} ]; then`, + ` printf 'WSL runtime archive does not match its recorded SHA-256 (expected %s, got %s)\\n' ${shellQuote(archiveSha256)} "$archive_sha" >&2`, + " exit 1", + "fi", + // A backend can still be running out of an unready tree: the probe revokes + // the ready marker without stopping the process it just failed for, and + // invalidation deliberately leaves the tree in place for exactly that + // reason. Deleting it here unlinks node_modules from under a live backend, + // which then breaks the moment it lazily loads anything it had not already + // read. Move it aside either way, but only delete it now when nothing is + // running from it; otherwise hand it to the pruner's scratch sweep, which + // is what that delay is for. A process's cmdline keeps the pre-rename path, + // so this has to be asked before the move, not after. This script arrives + // on stdin, so it cannot match itself. + "runtime_in_use() {", + // No /proc means no way to tell, and guessing wrong costs a live backend + // its runtime. Keeping the tree only costs disk until the sweep runs. + " [ -d /proc/1 ] || return 0", + ' grep -qF -- "$1/" /proc/[0-9]*/cmdline 2>/dev/null', + "}", + 'if [ -e "$runtime_root" ]; then', + ' if runtime_in_use "$runtime_root"; then', + " runtime_root_in_use=1", + " else", + " runtime_root_in_use=0", + " fi", + ` runtime_stale=$(mktemp -d "$runtime_parent/.${safeRuntimeId}.stale.XXXXXX")`, + ' rmdir "$runtime_stale"', + ' if mv -T "$runtime_root" "$runtime_stale" 2>/dev/null; then', + ' if [ "$runtime_root_in_use" = 1 ]; then', + // Renaming keeps the directory's old mtime, so restart the cleanup clock. + ' touch "$runtime_stale"', + " else", + ' rm -rf "$runtime_stale"', + " fi", + " fi", + "fi", + `runtime_tmp=$(mktemp -d "$runtime_parent/.${safeRuntimeId}.tmp.XXXXXX")`, + 'cleanup_runtime_install() { rm -rf "$runtime_tmp"; }', + "trap cleanup_runtime_install EXIT", + `tar -xzf ${shellQuote(linuxArchivePath)} -C "$runtime_tmp"`, + 'test -f "$runtime_tmp/apps/server/dist/bin.mjs"', + 'test -f "$runtime_tmp/node_modules/node-pty/package.json"', + + // Never write the ready marker over a tree that is missing the native + // payload. Failing here drops out to the mounted-tree fallback, which is + // recoverable; promoting it would mark the defect ready and cache it. + 'if ! node_pty_payload_present "$runtime_tmp"; then', + " printf 'WSL runtime archive is missing its Linux node-pty binary\\n' >&2", + " exit 1", + "fi", + // The archive's bytes were verified against archiveSha256 above, so the + // digest recorded here describes content this install proved. Every later + // warm reuse checks the entry against it. + 'installed_entry_digest=$(runtime_server_entry_digest "$runtime_tmp")', + 'if [ -z "$installed_entry_digest" ]; then', + " printf 'Could not hash the WSL runtime server entry\\n' >&2", + " exit 1", + "fi", + `printf '%s\\n' "$installed_entry_digest" > "$runtime_tmp/${WSL_RUNTIME_READY_MARKER}"`, + 'if mv -T "$runtime_tmp" "$runtime_root" 2>/dev/null; then', + " :", + "elif runtime_is_ready; then", + ' rm -rf "$runtime_tmp"', + "else", + ` printf 'Could not promote WSL runtime cache at %s\\n' "$runtime_root" >&2`, + " exit 1", + "fi", + `touch "$runtime_root/${WSL_RUNTIME_SELECTED_MARKER}"`, + `printf 'runtimeRoot:%s\\n' "$runtime_root"`, + ].join("\n"); +}; + +// An interrupted install leaves a dot-prefixed scratch directory behind. A cold +// install extracts a few hundred MB inside the distro, so two hours is far past +// any live install while still bounding how long an orphan survives. +const ORPHANED_RUNTIME_SCRATCH_MAX_AGE_MINUTES = 120; + +export const buildWslRuntimePruneScript = (runtimeId: string): string => { + const safeRuntimeId = sanitizeWslRuntimeId(runtimeId); + return [ + "set -eu", + 'runtime_parent="$HOME/.t3/wsl-runtime"', + `current_runtime="$runtime_parent/${safeRuntimeId}"`, + '[ -d "$runtime_parent" ] || exit 0', + // Serialize the whole retention decision so two backends cannot select + // different "previous" caches and delete around one another. + 'prune_lock="$runtime_parent/.prune.lock"', + 'exec 8> "$prune_lock"', + "flock -x 8", + // Without a way to see the distro's processes we cannot tell which caches + // are load-bearing, and the retention rules below are not safe on their own. + "[ -d /proc/1 ] || exit 0", + "runtime_in_use() {", + ' grep -qF -- "$1/" /proc/[0-9]*/cmdline 2>/dev/null', + "}", + 'previous_runtime=""', + 'for candidate in "$runtime_parent"/sha256-*; do', + ' [ -d "$candidate" ] || continue', + ' [ "$candidate" != "$current_runtime" ] || continue', + ` [ -f "$candidate/${WSL_RUNTIME_READY_MARKER}" ] || continue`, + ' if [ -z "$previous_runtime" ] || [ "$candidate" -nt "$previous_runtime" ]; then', + ' previous_runtime="$candidate"', + " fi", + "done", + // Only this desktop-owned prefix is eligible. Markerless roots are broken + // caches left by invalidation and must not become permanent disk leaks. + 'for candidate in "$runtime_parent"/sha256-*; do', + ' [ -d "$candidate" ] || continue', + ' [ "$candidate" != "$current_runtime" ] || continue', + ' [ "$candidate" != "$previous_runtime" ] || continue', + ' ! runtime_in_use "$candidate" || continue', + " candidate_name=${candidate##*/}", + ' candidate_lock="$runtime_parent/.${candidate_name}.install.lock"', + ' exec 9> "$candidate_lock"', + // A held lock means another launch is installing or repairing this cache. + // Skip instead of waiting or deleting underneath it. + " flock -n 9 || continue", + ` selected_marker="$candidate/${WSL_RUNTIME_SELECTED_MARKER}"`, + ` if [ -f "$selected_marker" ] && find "$selected_marker" -maxdepth 0 -mmin -${String(WSL_RUNTIME_SELECTION_GRACE_MINUTES)} -print -quit | grep -q .; then`, + " flock -u 9", + " continue", + " fi", + ' rm -rf -- "$candidate"', + " flock -u 9", + "done", + // Interrupted installs use dot-prefixed names under this dedicated parent. + 'for scratch in "$runtime_parent"/.*.tmp.* "$runtime_parent"/.*.stale.*; do', + ' [ -d "$scratch" ] || continue', + ` find "$scratch" -maxdepth 0 -mmin +${String(ORPHANED_RUNTIME_SCRATCH_MAX_AGE_MINUTES)} -print -quit | grep -q . || continue`, + ' rm -rf -- "$scratch"', + "done", + ].join("\n"); +}; + +// Drops the ready marker so the next launch reinstalls the runtime from the +// archive. Readiness is a presence check by design, so a cached tree whose +// native payload is present but unloadable (truncated pty.node, a distro whose +// glibc the binary needs and the tree was copied from another machine) stays +// ready forever and fails the probe on every launch. Only the probe can see +// that, so the probe is what revokes the marker. The tree itself is left in +// place: the install script moves an unready root aside before extracting. +export const buildWslRuntimeInvalidateScript = (runtimeId: string): string => { + const safeRuntimeId = sanitizeWslRuntimeId(runtimeId); + return [ + "set -eu", + `rm -f "$HOME/.t3/wsl-runtime/${safeRuntimeId}/${WSL_RUNTIME_READY_MARKER}"`, + ].join("\n"); +}; + +export const parseWslRuntimeRoot = (stdout: string): string | null => { + const prefix = "runtimeRoot:"; + const line = stdout.split("\n").find((candidate) => candidate.startsWith(prefix)); + if (line === undefined) return null; + const runtimeRoot = line.slice(prefix.length).replace(/\r$/, ""); + return runtimeRoot.startsWith("/") ? runtimeRoot : null; +}; + const NODE_PTY_PREBUILD_MISSING_EXIT_CODE = 4; export const formatNodePtyProbeFailureReason = (exitCode: number): string | null => @@ -390,23 +663,10 @@ export const formatMissingToolsReason = ( const ensureNodePtyImpl = ( distro: string | null, - windowsRepoRoot: string, - windowsToWslPath: ( - distro: string | null, - windowsPath: string, - ) => Effect.Effect>, + linuxRepoRoot: string, options: EnsureWslNodePtyOptions = {}, ): Effect.Effect => Effect.gen(function* () { - const linuxRepoRootOption = yield* windowsToWslPath(distro, windowsRepoRoot); - if (Option.isNone(linuxRepoRootOption)) { - return { - ok: false, - reason: `wslpath conversion failed for ${windowsRepoRoot}`, - fatal: false, - } as const; - } - const linuxRepoRoot = linuxRepoRootOption.value; // node-pty lives in the apps/server workspace's node_modules; resolve from // there rather than the monorepo root, where Bun's hoist layout omits it. const linuxServerDir = `${linuxRepoRoot}/apps/server`; @@ -584,6 +844,96 @@ const ensureNodePtyImpl = ( } as const; }); +const prepareWslRuntimeImpl = Effect.fn("desktop.wsl.prepareRuntimeImpl")(function* ( + distro: string | null, + archive: WslRuntimeArchive, + windowsToWslPath: ( + distro: string | null, + windowsPath: string, + ) => Effect.Effect>, +): Effect.fn.Return { + const linuxArchivePath = yield* windowsToWslPath(distro, archive.windowsPath); + if (Option.isNone(linuxArchivePath)) { + return { + ok: false, + reason: `wslpath conversion failed for ${archive.windowsPath}`, + } as const; + } + + const install = yield* runWslShell( + distro, + buildWslRuntimeInstallScript(linuxArchivePath.value, archive.runtimeId, archive.sha256), + RUNTIME_INSTALL_TIMEOUT, + { resolveNode: false }, + ); + if (install.transportFailure !== null) { + return { + ok: false, + reason: + install.transportFailure === "timeout" + ? "WSL runtime installation timed out. Check that the distro has free disk space, then retry." + : "WSL runtime installation lost communication with wsl.exe. Retry, or check that the distro is healthy.", + } as const; + } + if (install.exitCode !== 0) { + const trimmedTail = `${install.stdout}${install.stderr}`.trim().slice(-500); + return { + ok: false, + reason: `WSL runtime installation failed (exit ${install.exitCode}): ${trimmedTail || "no stderr captured"}`, + } as const; + } + + const linuxAppRoot = parseWslRuntimeRoot(install.stdout); + return linuxAppRoot === null + ? { + ok: false, + reason: "WSL runtime installation completed without reporting its cache path.", + } + : { ok: true, linuxAppRoot }; +}); + +const pruneWslRuntimesImpl = Effect.fn("desktop.wsl.pruneRuntimesImpl")(function* ( + distro: string | null, + runtimeId: string, +): Effect.fn.Return { + const result = yield* runWslShell( + distro, + buildWslRuntimePruneScript(runtimeId), + RUNTIME_PRUNE_TIMEOUT, + { resolveNode: false }, + ); + if (result.transportFailure === null && result.exitCode === 0) return; + + const detail = `${result.stdout}${result.stderr}`.trim().slice(-500); + yield* Effect.logWarning("Could not prune old WSL runtime caches.", { + distro, + runtimeId, + detail: detail || `exit ${result.exitCode}`, + }); +}); + +const invalidateWslRuntimeImpl = Effect.fn("desktop.wsl.invalidateRuntimeImpl")(function* ( + distro: string | null, + runtimeId: string, +): Effect.fn.Return { + const result = yield* runWslShell( + distro, + buildWslRuntimeInvalidateScript(runtimeId), + RUNTIME_INVALIDATE_TIMEOUT, + { resolveNode: false }, + ); + if (result.transportFailure === null && result.exitCode === 0) return; + + const detail = `${result.stdout}${result.stderr}`.trim().slice(-500); + // Best effort: the caller has already fallen back to the mounted tree, so a + // failure here only costs the reinstall that would have repaired the cache. + yield* Effect.logWarning("Could not invalidate the staged WSL runtime cache.", { + distro, + runtimeId, + detail: detail || `exit ${result.exitCode}`, + }); +}); + export const probeWslDistros: Effect.Effect< readonly WslDistro[], DesktopWslDistroListError, @@ -778,9 +1128,15 @@ export interface DesktopWslEnvironmentTestStub { readonly windowsToWslPath?: (distro: string | null, windowsPath: string) => Option.Option; readonly getUserHome?: (distro: string | null) => Option.Option; readonly getDistroIp?: (distro: string | null) => Option.Option; + readonly prepareRuntime?: ( + distro: string | null, + archive: WslRuntimeArchive, + ) => PrepareWslRuntimeResult; + readonly pruneRuntimes?: (distro: string | null, runtimeId: string) => Effect.Effect; + readonly invalidateRuntime?: (distro: string | null, runtimeId: string) => Effect.Effect; readonly ensureNodePty?: ( distro: string | null, - windowsRepoRoot: string, + linuxAppRoot: string, options?: EnsureWslNodePtyOptions, ) => EnsureWslNodePtyResult; } @@ -800,9 +1156,19 @@ export const layerTest = (stub: DesktopWslEnvironmentTestStub = {}) => { Effect.succeed(stub.windowsToWslPath?.(distro, windowsPath) ?? Option.none()), getUserHome: (distro) => Effect.succeed(stub.getUserHome?.(distro) ?? Option.none()), getDistroIp: (distro) => Effect.succeed(stub.getDistroIp?.(distro) ?? Option.none()), - ensureNodePty: (distro, windowsRepoRoot, options) => + prepareRuntime: (distro, archive) => + Effect.succeed( + stub.prepareRuntime?.(distro, archive) ?? { + ok: false, + reason: "prepareRuntime stub not configured", + }, + ), + pruneRuntimes: (distro, runtimeId) => stub.pruneRuntimes?.(distro, runtimeId) ?? Effect.void, + invalidateRuntime: (distro, runtimeId) => + stub.invalidateRuntime?.(distro, runtimeId) ?? Effect.void, + ensureNodePty: (distro, linuxAppRoot, options) => Effect.succeed( - stub.ensureNodePty?.(distro, windowsRepoRoot, options) ?? { + stub.ensureNodePty?.(distro, linuxAppRoot, options) ?? { ok: false, reason: "ensureNodePty stub not configured", fatal: true, @@ -882,8 +1248,20 @@ export const layer = Layer.effect( windowsToWslPath, getUserHome, getDistroIp, - ensureNodePty: (distro, windowsRepoRoot, options) => - provideSpawner(ensureNodePtyImpl(distro, windowsRepoRoot, windowsToWslPath, options)).pipe( + prepareRuntime: (distro, archive) => + provideSpawner(prepareWslRuntimeImpl(distro, archive, windowsToWslPath)).pipe( + Effect.withSpan("desktop.wsl.prepareRuntime"), + ), + pruneRuntimes: (distro, runtimeId) => + provideSpawner(pruneWslRuntimesImpl(distro, runtimeId)).pipe( + Effect.withSpan("desktop.wsl.pruneRuntimes"), + ), + invalidateRuntime: (distro, runtimeId) => + provideSpawner(invalidateWslRuntimeImpl(distro, runtimeId)).pipe( + Effect.withSpan("desktop.wsl.invalidateRuntime"), + ), + ensureNodePty: (distro, linuxAppRoot, options) => + provideSpawner(ensureNodePtyImpl(distro, linuxAppRoot, options)).pipe( Effect.withSpan("desktop.wsl.ensureNodePty"), ), }); diff --git a/apps/desktop/src/wsl/DesktopWslServerTree.test.ts b/apps/desktop/src/wsl/DesktopWslServerTree.test.ts index 8c1a5b020b1e..0c020806166a 100644 --- a/apps/desktop/src/wsl/DesktopWslServerTree.test.ts +++ b/apps/desktop/src/wsl/DesktopWslServerTree.test.ts @@ -297,6 +297,93 @@ describe("DesktopWslServerTree", () => { ).pipe(Effect.provide(NodeServices.layer)), ); + it.effect("removes the legacy Windows extraction tree without preparing a fallback", () => + withTempDir((tempDir) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const resourcesPath = path.join(tempDir, "resources"); + const treeRoot = path.join(tempDir, "userdata", "wsl-server-tree"); + yield* fileSystem.makeDirectory(path.join(treeRoot, "1.2.3"), { recursive: true }); + yield* fileSystem.writeFileString(path.join(treeRoot, "1.2.3", "legacy"), "old"); + + yield* Effect.gen(function* () { + const tree = yield* DesktopWslServerTree.DesktopWslServerTree; + yield* tree.cleanupLegacy; + }).pipe( + Effect.provide( + DesktopWslServerTree.layer.pipe( + Layer.provideMerge(environmentLayer({ baseDir: tempDir, resourcesPath })), + ), + ), + ); + + assert.isFalse(yield* fileSystem.exists(treeRoot)); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("re-extracts after legacy cleanup partially deletes the completed tree", () => + withTempDir((tempDir) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const resourcesPath = path.join(tempDir, "resources"); + const serverRoot = path.join(resourcesPath, "server.asar"); + const sourceEntryPath = path.join(serverRoot, "apps/server/dist/bin.mjs"); + yield* fileSystem.makeDirectory(path.dirname(sourceEntryPath), { recursive: true }); + yield* fileSystem.writeFileString(sourceEntryPath, "fresh-server-entry"); + + const initial = yield* ensureWith({ baseDir: tempDir, resourcesPath }); + assert.isTrue(initial.ok); + const versionDir = initial.ok ? initial.root : ""; + const treeRoot = path.dirname(versionDir); + const extractedEntryPath = path.join(versionDir, "apps/server/dist/bin.mjs"); + let cleanupFailed = false; + const partialCleanupFileSystem = Layer.effect( + FileSystem.FileSystem, + Effect.gen(function* () { + const realFileSystem = yield* FileSystem.FileSystem; + return { + ...realFileSystem, + remove: (target, options) => + String(target) === treeRoot && options?.recursive === true && !cleanupFailed + ? Effect.gen(function* () { + cleanupFailed = true; + yield* realFileSystem.remove(extractedEntryPath); + return yield* PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "remove", + pathOrDescriptor: treeRoot, + description: "simulated partial legacy cleanup", + }); + }) + : realFileSystem.remove(target, options), + } satisfies FileSystem.FileSystem; + }), + ).pipe(Layer.provide(NodeServices.layer)); + + const result = yield* Effect.gen(function* () { + const tree = yield* DesktopWslServerTree.DesktopWslServerTree; + yield* tree.cleanupLegacy; + return yield* tree.ensure; + }).pipe( + Effect.provide( + DesktopWslServerTree.layer.pipe( + Layer.provideMerge(environmentLayer({ baseDir: tempDir, resourcesPath })), + Layer.provideMerge(partialCleanupFileSystem), + ), + ), + ); + + assert.isTrue(cleanupFailed); + assert.isTrue(result.ok); + assert.equal(yield* fileSystem.readFileString(extractedEntryPath), "fresh-server-entry"); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + it.effect("reports a retryable failure when the archive cannot be read", () => withTempDir((tempDir) => Effect.gen(function* () { diff --git a/apps/desktop/src/wsl/DesktopWslServerTree.ts b/apps/desktop/src/wsl/DesktopWslServerTree.ts index 0b87f7bf1fe0..c6094e780caf 100644 --- a/apps/desktop/src/wsl/DesktopWslServerTree.ts +++ b/apps/desktop/src/wsl/DesktopWslServerTree.ts @@ -11,10 +11,9 @@ import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; // Packaged Windows builds ship the server tree inside resources/server.asar // (see scripts/build-desktop-artifact.ts). The Windows primary reads it in // place through the asar-aware ELECTRON_RUN_AS_NODE runtime, but the WSL -// backend launches plain `wsl.exe -- node`, which cannot read an asar -// archive. This service materializes the archive into a real, version-keyed -// directory the first time the WSL backend starts, and reuses it afterwards — -// so only users who enable WSL ever pay for a loose copy of the server tree. +// backend launches plain `wsl.exe -- node`, which cannot read an asar archive. +// This fallback service materializes the archive into a real, version-keyed +// directory only when the distro-local runtime cannot be prepared. // // Reading through Electron's patched fs also transparently returns the // contents of files that electron-builder/asar left in the server.asar.unpacked @@ -52,6 +51,10 @@ export class DesktopWslServerTree extends Context.Service< // the checkout already is that directory; packaged Windows builds extract // server.asar on first use. readonly ensure: Effect.Effect; + // Removes the Windows-side extraction cache after a distro-local runtime + // has proven healthy. Serialized with ensure so cleanup cannot race an + // extraction that the mounted fallback is preparing. + readonly cleanupLegacy: Effect.Effect; } >()("@t3tools/desktop/wsl/DesktopWslServerTree") {} @@ -173,6 +176,28 @@ export const make = Effect.gen(function* () { // first caller extracts, later callers see the marker and reuse the tree. const gate = yield* Semaphore.make(1); + const cleanupLegacy = gate + .withPermits(1)( + needsExtraction + ? Effect.gen(function* () { + // Invalidate completeness before recursive deletion. Windows can + // remove part of a tree and then fail on a locked file; without + // this ordering, a surviving marker makes ensure reuse that + // half-deleted fallback instead of extracting it again. + yield* fs.remove(join(versionDir, MARKER_FILE_NAME), { force: true }); + yield* fs.remove(treeRoot, { recursive: true, force: true }); + }).pipe( + Effect.catch((cause) => + Effect.logWarning("[wsl-server-tree] Could not remove the legacy extraction cache.", { + treeRoot, + cause, + }), + ), + ) + : Effect.void, + ) + .pipe(Effect.withSpan("desktop.wslServerTree.cleanupLegacy")); + const ensure: Effect.Effect = gate .withPermits(1)( Effect.gen(function* () { @@ -205,13 +230,14 @@ export const make = Effect.gen(function* () { ) .pipe(Effect.withSpan("desktop.wslServerTree.ensure")); - return DesktopWslServerTree.of({ ensure }); + return DesktopWslServerTree.of({ ensure, cleanupLegacy }); }); export const layer = Layer.effect(DesktopWslServerTree, make); export interface DesktopWslServerTreeTestStub { readonly result?: WslServerTreeResult; + readonly cleanupLegacy?: Effect.Effect; } export const layerTest = (stub: DesktopWslServerTreeTestStub = {}) => @@ -221,6 +247,7 @@ export const layerTest = (stub: DesktopWslServerTreeTestStub = {}) => const environment = yield* DesktopEnvironment.DesktopEnvironment; return DesktopWslServerTree.of({ ensure: Effect.succeed(stub.result ?? { ok: true, root: environment.appRoot }), + cleanupLegacy: stub.cleanupLegacy ?? Effect.void, }); }), ); diff --git a/apps/mobile/README.md b/apps/mobile/README.md index 0eb865cb79bc..ed95b060fe3a 100644 --- a/apps/mobile/README.md +++ b/apps/mobile/README.md @@ -28,6 +28,23 @@ Start Metro for the dev client: vp run dev:client ``` +Metro keeps its transform cache between ordinary starts. If the cache itself is causing stale or +invalid output, clear it for one development-client start: + +```bash +vp run dev:client:reset +``` + +Run that reset once after installing or changing the Uniwind dependency patch. Cached transforms +can otherwise reference its previous pnpm package path. Ordinary Metro starts still keep the cache. + +Component edits use Fast Refresh. Connection-runtime edits replace the active Effect layer through +a stable atom runtime, preserving navigation and existing atom subscribers. Replaced registries +and managed runtimes dispose their resources; the app does not force a JavaScript reload. The Uniwind patch +skips global style invalidation when generated styles and themes are unchanged, while real style +changes still refresh. See [mobile development lifecycle](../../docs/internals/mobile-development.md) +for the lifetime boundaries. + Build and run the local iOS dev client: ```bash @@ -89,7 +106,9 @@ The native lint task runs SwiftLint for Swift plus ktlint and detekt for Kotlin. ## EAS Builds -CI uses Expo fingerprinting with the `preview:dev` profile to reuse an existing compatible build when possible, or start a new internal EAS build when native runtime inputs change. Production and default local builds continue to use the `appVersion` runtime policy. +Preview and production variants use Expo fingerprinting so OTA updates only reach binaries with matching native dependencies, config plugins, and patches. CI uses the `preview:dev` profile to reuse a compatible native build when possible. + +The development variant uses `appVersion` to avoid recalculating the native fingerprint for each Metro launch manifest. `MOBILE_VERSION_POLICY` can override either default. If you distribute a custom Release build with the development identity and publish OTA updates to it, set `MOBILE_VERSION_POLICY=fingerprint` for both its build and updates. Changing the runtime policy requires a native rebuild for OTA matching; an existing dev client can still load local Metro bundles. For preview or production EAS environments, set `T3CODE_CLERK_PUBLISHABLE_KEY`, `T3CODE_CLERK_JWT_TEMPLATE`, and `T3CODE_RELAY_URL` diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 9a51725478e1..c4a7717cd46a 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -10,11 +10,17 @@ Object.assign(process.env, repoEnv); const APP_VARIANT = resolveAppVariant(repoEnv.APP_VARIANT); const isIosPersonalTeamBuild = repoEnv.T3CODE_IOS_PERSONAL_TEAM === "1"; +const runtimeVersionPolicy = + process.env.MOBILE_VERSION_POLICY ?? + (APP_VARIANT === "development" ? "appVersion" : "fingerprint"); const personalTeamBundleIdentifier = repoEnv.T3CODE_IOS_PERSONAL_TEAM_BUNDLE_ID?.trim(); const IOS_BUNDLE_IDENTIFIER_PATTERN = /^[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+$/; const fromRepoRoot = (relativePath: string) => `../../${relativePath}`; +// Universal exports already contain their own rounded-square silhouette. Using one as an adaptive +// foreground makes Android draw an icon shape inside the launcher's mask. +const androidAdaptiveForeground = "./assets/android-icon-foreground.png"; if ( isIosPersonalTeamBuild && @@ -30,7 +36,7 @@ const DEVELOPMENT_ASSETS = { appIcon: fromRepoRoot(BRAND_ASSET_PATHS.developmentIosIconPng), iosIcon: fromRepoRoot(BRAND_ASSET_PATHS.developmentIconComposerProject), splashIcon: fromRepoRoot(BRAND_ASSET_PATHS.developmentIosIconPng), - androidAdaptiveForeground: fromRepoRoot(BRAND_ASSET_PATHS.developmentUniversalIconPng), + androidAdaptiveForeground, androidAdaptiveBackgroundColor: "#00639B", androidMonochromeIcon: "./assets/android-icon-mark.png", androidNotificationIcon: "./assets/android-notification-icon.png", @@ -41,7 +47,7 @@ const PREVIEW_ASSETS = { appIcon: fromRepoRoot(BRAND_ASSET_PATHS.nightlyIosIconPng), iosIcon: fromRepoRoot(BRAND_ASSET_PATHS.nightlyIconComposerProject), splashIcon: fromRepoRoot(BRAND_ASSET_PATHS.nightlyIosIconPng), - androidAdaptiveForeground: fromRepoRoot(BRAND_ASSET_PATHS.nightlyLinuxIconPng), + androidAdaptiveForeground, androidAdaptiveBackgroundColor: "#111533", androidMonochromeIcon: "./assets/android-icon-mark.png", androidNotificationIcon: "./assets/android-notification-icon.png", @@ -52,7 +58,7 @@ const RELEASE_ASSETS = { appIcon: fromRepoRoot(BRAND_ASSET_PATHS.productionIosIconPng), iosIcon: fromRepoRoot(BRAND_ASSET_PATHS.productionIconComposerProject), splashIcon: fromRepoRoot(BRAND_ASSET_PATHS.productionIosIconPng), - androidAdaptiveForeground: "./assets/android-icon-mark.png", + androidAdaptiveForeground, androidAdaptiveBackgroundColor: "#000000", androidMonochromeIcon: "./assets/android-icon-mark.png", androidNotificationIcon: "./assets/android-notification-icon.png", @@ -142,12 +148,14 @@ const sharingPlugin: NonNullable[number] = [ supportsText: true, supportsWebUrlWithMaxCount: 1, supportsImageWithMaxCount: 8, + supportsMovieWithMaxCount: 8, + supportsFileWithMaxCount: 8, }, }, android: { enabled: true, - singleShareMimeTypes: ["text/plain", "image/*"], - multipleShareMimeTypes: ["image/*"], + singleShareMimeTypes: ["*/*"], + multipleShareMimeTypes: ["*/*"], }, }, ]; @@ -163,11 +171,10 @@ const config: ExpoConfig = { scheme: variant.scheme, version: "1.0.4", runtimeVersion: { - // Fingerprint (not appVersion) so an OTA only reaches binaries whose native - // project — native deps, config plugins, AND patches/ — matches the update. - // With appVersion, every 0.1.0 build shares a runtime version, so a JS update - // could land on a binary missing the native changes it needs and crash. - policy: process.env.MOBILE_VERSION_POLICY ?? "fingerprint", + // Development manifests resolve on every launch, so avoid fingerprint's + // expensive native-project calculation there. Preview and production stay + // fingerprinted so OTAs only reach binaries with matching native projects. + policy: runtimeVersionPolicy, }, orientation: "portrait", icon: variant.assets.appIcon, @@ -199,6 +206,7 @@ const config: ExpoConfig = { }, NSLocalNetworkUsageDescription: "Allow T3 Code to connect to T3 Code servers on your local network or tailnet.", + NSPhotoLibraryAddUsageDescription: "Allow T3 Code to save images to your photo library.", ITSAppUsesNonExemptEncryption: false, // The App Store screenshot harness rotates the iPad interface from // inside the app (CI denies osascript the Accessibility access that @@ -289,6 +297,15 @@ const config: ExpoConfig = { }, }, ], + [ + "expo-audio", + { + microphonePermission: "Allow T3 Code to use your microphone for voice input.", + recordAudioAndroid: false, + enableBackgroundPlayback: false, + enableBackgroundRecording: false, + }, + ], [ "expo-camera", { diff --git a/apps/mobile/assets/android-icon-foreground.png b/apps/mobile/assets/android-icon-foreground.png new file mode 100644 index 000000000000..4f4374c7ebcf Binary files /dev/null and b/apps/mobile/assets/android-icon-foreground.png differ diff --git a/apps/mobile/assets/android-icon-foreground.svg b/apps/mobile/assets/android-icon-foreground.svg new file mode 100644 index 000000000000..8d974992ab2c --- /dev/null +++ b/apps/mobile/assets/android-icon-foreground.svg @@ -0,0 +1,8 @@ + + + + + diff --git a/apps/mobile/src/lib/mobileDefaultTheme.ts b/apps/mobile/generated-uniwind-default-theme-variables.json similarity index 93% rename from apps/mobile/src/lib/mobileDefaultTheme.ts rename to apps/mobile/generated-uniwind-default-theme-variables.json index 66afae46473b..427d370acb57 100644 --- a/apps/mobile/src/lib/mobileDefaultTheme.ts +++ b/apps/mobile/generated-uniwind-default-theme-variables.json @@ -1,8 +1,5 @@ -import type { MobileThemeVariables } from "./mobileTheme"; - -/** The existing T3 Code mobile palette, retained as the upgrade-safe default. */ -export const DEFAULT_MOBILE_THEME_VARIABLES = { - light: { +{ + "light": { "--color-screen": "#f2f2f7", "--color-sheet": "rgba(242, 242, 247, 0.98)", "--color-sheet-solid": "#f2f2f7", @@ -67,9 +64,9 @@ export const DEFAULT_MOBILE_THEME_VARIABLES = { "--color-drawer-shadow": "rgba(0, 0, 0, 0.12)", "--color-dot-separator": "rgba(0, 0, 0, 0.2)", "--color-wordmark": "#262626", - "--color-chevron": "rgba(0, 0, 0, 0.2)", + "--color-chevron": "rgba(0, 0, 0, 0.2)" }, - dark: { + "dark": { "--color-screen": "#0a0a0a", "--color-sheet": "rgba(14, 14, 14, 0.98)", "--color-sheet-solid": "#0e0e0e", @@ -134,6 +131,6 @@ export const DEFAULT_MOBILE_THEME_VARIABLES = { "--color-drawer-shadow": "rgba(0, 0, 0, 0.32)", "--color-dot-separator": "rgba(255, 255, 255, 0.2)", "--color-wordmark": "#f5f5f5", - "--color-chevron": "rgba(255, 255, 255, 0.2)", - }, -} as const satisfies Readonly>; + "--color-chevron": "rgba(255, 255, 255, 0.2)" + } +} diff --git a/apps/mobile/generated-uniwind-theme-names.json b/apps/mobile/generated-uniwind-theme-names.json new file mode 100644 index 000000000000..4ec9f01f8ee7 --- /dev/null +++ b/apps/mobile/generated-uniwind-theme-names.json @@ -0,0 +1,12 @@ +[ + "t3-chat-light", + "t3-chat-dark", + "grove-light", + "grove-dark", + "ocean-light", + "ocean-dark", + "ember-light", + "ember-dark", + "iris-light", + "iris-dark" +] diff --git a/apps/mobile/generated-uniwind-themes.css b/apps/mobile/generated-uniwind-themes.css new file mode 100644 index 000000000000..7f8f9c16afca --- /dev/null +++ b/apps/mobile/generated-uniwind-themes.css @@ -0,0 +1,1374 @@ +/* Generated by scripts/generate-uniwind-themes.mts. Do not edit manually. */ +@layer theme { + :root { + @variant light { + --color-adaptive-amber-50-950-a40: oklch(98.7% 0.022 95.277); + --color-adaptive-amber-200-900-a60: oklch(92.4% 0.12 95.746); + --color-adaptive-amber-500-a12-a16: oklch(76.9% 0.188 70.08 / 12%); + --color-adaptive-amber-700-300: oklch(55.5% 0.163 48.998); + --color-adaptive-amber-700-400: oklch(55.5% 0.163 48.998); + --color-adaptive-amber-800-200: oklch(47.3% 0.137 46.201); + --color-adaptive-blue-50-blue-400-a14: oklch(97% 0.014 254.604); + --color-adaptive-blue-300-a50-blue-400-a28: oklch(80.9% 0.105 251.813 / 50%); + --color-adaptive-blue-500-a20-blue-400-a15: oklch(62.3% 0.214 259.815 / 20%); + --color-adaptive-blue-500-400: oklch(62.3% 0.214 259.815); + --color-adaptive-blue-600-400: oklch(54.6% 0.245 262.881); + --color-adaptive-black-a10-a25: rgb(0 0 0 / 10%); + --color-adaptive-black-a15-a35: rgb(0 0 0 / 15%); + --color-adaptive-emerald-500-a12-a16: oklch(69.6% 0.17 162.48 / 12%); + --color-adaptive-emerald-600-400: oklch(59.6% 0.145 163.225); + --color-adaptive-emerald-700-300: oklch(50.8% 0.118 165.612); + --color-adaptive-indigo-500-a12-a16: oklch(58.5% 0.233 277.117 / 12%); + --color-adaptive-indigo-600-300: oklch(51.1% 0.262 276.966); + --color-adaptive-indigo-700-300: oklch(45.7% 0.24 277.023); + --color-adaptive-neutral-100-900: oklch(97% 0 0); + --color-adaptive-neutral-200-700-a60: oklch(92.2% 0 0); + --color-adaptive-neutral-200-800: oklch(92.2% 0 0); + --color-adaptive-neutral-200-a70-white-a8: oklch(92.2% 0 0 / 70%); + --color-adaptive-neutral-200-white-a6: oklch(92.2% 0 0); + --color-adaptive-neutral-200-white-a8: oklch(92.2% 0 0); + --color-adaptive-neutral-200-a80-white-a8: oklch(92.2% 0 0 / 80%); + --color-adaptive-neutral-300-a60-white-a12: oklch(87% 0 0 / 60%); + --color-adaptive-neutral-400-500: oklch(70.8% 0 0); + --color-adaptive-neutral-400-a60-500-a60: oklch(70.8% 0 0 / 60%); + --color-adaptive-neutral-400-a80-500-a80: oklch(70.8% 0 0 / 80%); + --color-adaptive-neutral-500-a10-a16: oklch(55.6% 0 0 / 10%); + --color-adaptive-neutral-500-400: oklch(55.6% 0 0); + --color-adaptive-neutral-500-500: oklch(55.6% 0 0); + --color-adaptive-neutral-600-300: oklch(43.9% 0 0); + --color-adaptive-neutral-600-400: oklch(43.9% 0 0); + --color-adaptive-neutral-950-50: oklch(14.5% 0 0); + --color-adaptive-red-50-950-a80: oklch(97.1% 0.013 17.38); + --color-adaptive-red-200-800: oklch(88.5% 0.062 18.334); + --color-adaptive-red-600-a80-400-a80: oklch(57.7% 0.245 27.325 / 80%); + --color-adaptive-red-700-300: oklch(50.5% 0.213 27.518); + --color-adaptive-rose-100-500-a18: oklch(94.1% 0.03 12.58); + --color-adaptive-rose-100-a80-500-a12: oklch(94.1% 0.03 12.58 / 80%); + --color-adaptive-rose-300-a70-400-a28: oklch(81% 0.117 11.638 / 70%); + --color-adaptive-rose-500-a12-a16: oklch(64.5% 0.246 16.439 / 12%); + --color-adaptive-rose-500-400: oklch(64.5% 0.246 16.439); + --color-adaptive-rose-600-400: oklch(58.6% 0.253 17.585); + --color-adaptive-rose-700-300: oklch(51.4% 0.222 16.935); + --color-adaptive-sky-500-a12-a16: oklch(68.5% 0.169 237.323 / 12%); + --color-adaptive-sky-600-400: oklch(58.8% 0.158 241.966); + --color-adaptive-sky-700-300: oklch(50% 0.134 242.749); + --color-adaptive-violet-500-a12-a16: oklch(60.6% 0.25 292.717 / 12%); + --color-adaptive-violet-600-400: oklch(54.1% 0.281 293.009); + --color-adaptive-violet-700-300: oklch(49.1% 0.27 292.581); + --color-adaptive-white-neutral-950-a70: #fff; + --color-adaptive-zinc-500-a12-a16: oklch(55.2% 0.016 285.938 / 12%); + --color-adaptive-zinc-500-400: oklch(55.2% 0.016 285.938); + --color-adaptive-zinc-600-300: oklch(44.2% 0.017 285.786); + } + + @variant dark { + --color-adaptive-amber-50-950-a40: oklch(27.9% 0.077 45.635 / 40%); + --color-adaptive-amber-200-900-a60: oklch(41.4% 0.112 45.904 / 60%); + --color-adaptive-amber-500-a12-a16: oklch(76.9% 0.188 70.08 / 16%); + --color-adaptive-amber-700-300: oklch(87.9% 0.169 91.605); + --color-adaptive-amber-700-400: oklch(82.8% 0.189 84.429); + --color-adaptive-amber-800-200: oklch(92.4% 0.12 95.746); + --color-adaptive-blue-50-blue-400-a14: oklch(70.7% 0.165 254.624 / 14%); + --color-adaptive-blue-300-a50-blue-400-a28: oklch(70.7% 0.165 254.624 / 28%); + --color-adaptive-blue-500-a20-blue-400-a15: oklch(70.7% 0.165 254.624 / 15%); + --color-adaptive-blue-500-400: oklch(70.7% 0.165 254.624); + --color-adaptive-blue-600-400: oklch(70.7% 0.165 254.624); + --color-adaptive-black-a10-a25: rgb(0 0 0 / 25%); + --color-adaptive-black-a15-a35: rgb(0 0 0 / 35%); + --color-adaptive-emerald-500-a12-a16: oklch(69.6% 0.17 162.48 / 16%); + --color-adaptive-emerald-600-400: oklch(76.5% 0.177 163.223); + --color-adaptive-emerald-700-300: oklch(84.5% 0.143 164.978); + --color-adaptive-indigo-500-a12-a16: oklch(58.5% 0.233 277.117 / 16%); + --color-adaptive-indigo-600-300: oklch(78.5% 0.115 274.713); + --color-adaptive-indigo-700-300: oklch(78.5% 0.115 274.713); + --color-adaptive-neutral-100-900: oklch(20.5% 0 0); + --color-adaptive-neutral-200-700-a60: oklch(37.1% 0 0 / 60%); + --color-adaptive-neutral-200-800: oklch(26.9% 0 0); + --color-adaptive-neutral-200-a70-white-a8: rgb(255 255 255 / 8%); + --color-adaptive-neutral-200-white-a6: rgb(255 255 255 / 6%); + --color-adaptive-neutral-200-white-a8: rgb(255 255 255 / 8%); + --color-adaptive-neutral-200-a80-white-a8: rgb(255 255 255 / 8%); + --color-adaptive-neutral-300-a60-white-a12: rgb(255 255 255 / 12%); + --color-adaptive-neutral-400-500: oklch(55.6% 0 0); + --color-adaptive-neutral-400-a60-500-a60: oklch(55.6% 0 0 / 60%); + --color-adaptive-neutral-400-a80-500-a80: oklch(55.6% 0 0 / 80%); + --color-adaptive-neutral-500-a10-a16: oklch(55.6% 0 0 / 16%); + --color-adaptive-neutral-500-400: oklch(70.8% 0 0); + --color-adaptive-neutral-500-500: oklch(55.6% 0 0); + --color-adaptive-neutral-600-300: oklch(87% 0 0); + --color-adaptive-neutral-600-400: oklch(70.8% 0 0); + --color-adaptive-neutral-950-50: oklch(98.5% 0 0); + --color-adaptive-red-50-950-a80: oklch(25.8% 0.092 26.042 / 80%); + --color-adaptive-red-200-800: oklch(44.4% 0.177 26.899); + --color-adaptive-red-600-a80-400-a80: oklch(70.4% 0.191 22.216 / 80%); + --color-adaptive-red-700-300: oklch(80.8% 0.114 19.571); + --color-adaptive-rose-100-500-a18: oklch(64.5% 0.246 16.439 / 18%); + --color-adaptive-rose-100-a80-500-a12: oklch(64.5% 0.246 16.439 / 12%); + --color-adaptive-rose-300-a70-400-a28: oklch(71.2% 0.194 13.428 / 28%); + --color-adaptive-rose-500-a12-a16: oklch(64.5% 0.246 16.439 / 16%); + --color-adaptive-rose-500-400: oklch(71.2% 0.194 13.428); + --color-adaptive-rose-600-400: oklch(71.2% 0.194 13.428); + --color-adaptive-rose-700-300: oklch(81% 0.117 11.638); + --color-adaptive-sky-500-a12-a16: oklch(68.5% 0.169 237.323 / 16%); + --color-adaptive-sky-600-400: oklch(74.6% 0.16 232.661); + --color-adaptive-sky-700-300: oklch(82.8% 0.111 230.318); + --color-adaptive-violet-500-a12-a16: oklch(60.6% 0.25 292.717 / 16%); + --color-adaptive-violet-600-400: oklch(70.2% 0.183 293.541); + --color-adaptive-violet-700-300: oklch(81.1% 0.111 293.571); + --color-adaptive-white-neutral-950-a70: oklch(14.5% 0 0 / 70%); + --color-adaptive-zinc-500-a12-a16: oklch(55.2% 0.016 285.938 / 16%); + --color-adaptive-zinc-500-400: oklch(70.5% 0.015 286.067); + --color-adaptive-zinc-600-300: oklch(87.1% 0.006 286.286); + } + + @variant t3-chat-light { + --color-screen: #fdf7fd; + --color-sheet: rgba(253, 247, 253, 0.98); + --color-sheet-solid: #fdf7fd; + --color-card: #fdfafd; + --color-card-alt: #faf3fb; + --color-card-translucent: rgba(253, 250, 253, 0.8); + --color-foreground: #501854; + --color-foreground-secondary: #ac1668; + --color-foreground-muted: #8d1255; + --color-foreground-tertiary: #ac1668; + --color-border: #eee1ed; + --color-border-subtle: rgba(238, 225, 237, 0.7); + --color-separator: rgba(238, 225, 237, 0.55); + --color-subtle: #eaa7cb; + --color-subtle-strong: #f1c4e6; + --color-inline-skill-background: #f3e6f5; + --color-inline-skill-border: rgba(219, 39, 119, 0.42); + --color-inline-skill-foreground: #454554; + --color-primary: #db2777; + --color-primary-foreground: #ffffff; + --color-primary-shadow: #000000; + --color-secondary: #f1c4e6; + --color-secondary-foreground: #77347c; + --color-secondary-border: #eee1ed; + --color-switch-active-track: #db2777; + --color-switch-active-thumb: #ffffff; + --color-switch-inactive-track: #f1c4e6; + --color-switch-inactive-thumb: #8d1255; + --color-danger: #fde4f1; + --color-danger-border: rgba(247, 8, 108, 0.32); + --color-danger-foreground: #9d174d; + --color-input: #fdfafd; + --color-input-border: #e7c1dc; + --color-sidebar-search: #f8f8f7; + --color-placeholder: #8b5f90; + --color-icon: #501854; + --color-icon-muted: #ac1668; + --color-icon-subtle: #ac1668; + --color-header: rgba(253, 247, 253, 0.97); + --color-header-border: #efbdeb; + --color-glass-surface: rgba(255, 255, 255, 0.74); + --color-glass-tint: rgba(255, 255, 255, 0.22); + --color-status-bar: #fdf7fd; + --color-md-body: #501854; + --color-md-strong: #501854; + --color-md-link: #db2777; + --color-md-blockquote-border: #eee1ed; + --color-md-blockquote-bg: #eaa7cb; + --color-md-code-bg: #f5ecf9; + --color-md-code-text: #673c8b; + --color-md-user-code-bg: rgba(73, 44, 97, 0.18); + --color-md-user-code-text: #492c61; + --color-md-user-fence-bg: rgba(0, 0, 0, 0.16); + --color-md-user-fence-text: #492c61; + --color-md-hr: #eee1ed; + --color-user-bubble: #f7def2; + --color-user-bubble-foreground: #492c61; + --color-user-bubble-foreground-muted: rgba(73, 44, 97, 0.78); + --color-user-bubble-skill-foreground: #c12269; + --color-backdrop: rgba(0, 0, 0, 0.22); + --color-drawer: rgba(242, 225, 244, 0.99); + --color-drawer-shadow: rgba(0, 0, 0, 0.12); + --color-dot-separator: rgba(172, 22, 104, 0.35); + --color-wordmark: #501854; + --color-chevron: rgba(172, 22, 104, 0.42); + --color-adaptive-amber-50-950-a40: oklch(98.7% 0.022 95.277); + --color-adaptive-amber-200-900-a60: oklch(92.4% 0.12 95.746); + --color-adaptive-amber-500-a12-a16: oklch(76.9% 0.188 70.08 / 12%); + --color-adaptive-amber-700-300: oklch(55.5% 0.163 48.998); + --color-adaptive-amber-700-400: oklch(55.5% 0.163 48.998); + --color-adaptive-amber-800-200: oklch(47.3% 0.137 46.201); + --color-adaptive-blue-50-blue-400-a14: oklch(97% 0.014 254.604); + --color-adaptive-blue-300-a50-blue-400-a28: oklch(80.9% 0.105 251.813 / 50%); + --color-adaptive-blue-500-a20-blue-400-a15: oklch(62.3% 0.214 259.815 / 20%); + --color-adaptive-blue-500-400: oklch(62.3% 0.214 259.815); + --color-adaptive-blue-600-400: oklch(54.6% 0.245 262.881); + --color-adaptive-black-a10-a25: rgb(0 0 0 / 10%); + --color-adaptive-black-a15-a35: rgb(0 0 0 / 15%); + --color-adaptive-emerald-500-a12-a16: oklch(69.6% 0.17 162.48 / 12%); + --color-adaptive-emerald-600-400: oklch(59.6% 0.145 163.225); + --color-adaptive-emerald-700-300: oklch(50.8% 0.118 165.612); + --color-adaptive-indigo-500-a12-a16: oklch(58.5% 0.233 277.117 / 12%); + --color-adaptive-indigo-600-300: oklch(51.1% 0.262 276.966); + --color-adaptive-indigo-700-300: oklch(45.7% 0.24 277.023); + --color-adaptive-neutral-100-900: oklch(97% 0 0); + --color-adaptive-neutral-200-700-a60: oklch(92.2% 0 0); + --color-adaptive-neutral-200-800: oklch(92.2% 0 0); + --color-adaptive-neutral-200-a70-white-a8: oklch(92.2% 0 0 / 70%); + --color-adaptive-neutral-200-white-a6: oklch(92.2% 0 0); + --color-adaptive-neutral-200-white-a8: oklch(92.2% 0 0); + --color-adaptive-neutral-200-a80-white-a8: oklch(92.2% 0 0 / 80%); + --color-adaptive-neutral-300-a60-white-a12: oklch(87% 0 0 / 60%); + --color-adaptive-neutral-400-500: oklch(70.8% 0 0); + --color-adaptive-neutral-400-a60-500-a60: oklch(70.8% 0 0 / 60%); + --color-adaptive-neutral-400-a80-500-a80: oklch(70.8% 0 0 / 80%); + --color-adaptive-neutral-500-a10-a16: oklch(55.6% 0 0 / 10%); + --color-adaptive-neutral-500-400: oklch(55.6% 0 0); + --color-adaptive-neutral-500-500: oklch(55.6% 0 0); + --color-adaptive-neutral-600-300: oklch(43.9% 0 0); + --color-adaptive-neutral-600-400: oklch(43.9% 0 0); + --color-adaptive-neutral-950-50: oklch(14.5% 0 0); + --color-adaptive-red-50-950-a80: oklch(97.1% 0.013 17.38); + --color-adaptive-red-200-800: oklch(88.5% 0.062 18.334); + --color-adaptive-red-600-a80-400-a80: oklch(57.7% 0.245 27.325 / 80%); + --color-adaptive-red-700-300: oklch(50.5% 0.213 27.518); + --color-adaptive-rose-100-500-a18: oklch(94.1% 0.03 12.58); + --color-adaptive-rose-100-a80-500-a12: oklch(94.1% 0.03 12.58 / 80%); + --color-adaptive-rose-300-a70-400-a28: oklch(81% 0.117 11.638 / 70%); + --color-adaptive-rose-500-a12-a16: oklch(64.5% 0.246 16.439 / 12%); + --color-adaptive-rose-500-400: oklch(64.5% 0.246 16.439); + --color-adaptive-rose-600-400: oklch(58.6% 0.253 17.585); + --color-adaptive-rose-700-300: oklch(51.4% 0.222 16.935); + --color-adaptive-sky-500-a12-a16: oklch(68.5% 0.169 237.323 / 12%); + --color-adaptive-sky-600-400: oklch(58.8% 0.158 241.966); + --color-adaptive-sky-700-300: oklch(50% 0.134 242.749); + --color-adaptive-violet-500-a12-a16: oklch(60.6% 0.25 292.717 / 12%); + --color-adaptive-violet-600-400: oklch(54.1% 0.281 293.009); + --color-adaptive-violet-700-300: oklch(49.1% 0.27 292.581); + --color-adaptive-white-neutral-950-a70: #fff; + --color-adaptive-zinc-500-a12-a16: oklch(55.2% 0.016 285.938 / 12%); + --color-adaptive-zinc-500-400: oklch(55.2% 0.016 285.938); + --color-adaptive-zinc-600-300: oklch(44.2% 0.017 285.786); + } + + @variant t3-chat-dark { + --color-screen: #1f1a24; + --color-sheet: rgba(31, 26, 36, 0.98); + --color-sheet-solid: #1f1a24; + --color-card: #2c2631; + --color-card-alt: #29232d; + --color-card-translucent: rgba(44, 38, 49, 0.8); + --color-foreground: #f9f8fb; + --color-foreground-secondary: #e7d0dd; + --color-foreground-muted: #e7d0dd; + --color-foreground-tertiary: #e7d0dd; + --color-border: #27242c; + --color-border-subtle: rgba(39, 36, 44, 0.7); + --color-separator: rgba(39, 36, 44, 0.55); + --color-subtle: #423a45; + --color-subtle-strong: #362d3d; + --color-inline-skill-background: #463753; + --color-inline-skill-border: rgba(163, 0, 76, 0.42); + --color-inline-skill-foreground: #f8f1f5; + --color-primary: #a3004c; + --color-primary-foreground: #fbd0e8; + --color-primary-shadow: #000000; + --color-secondary: #362d3d; + --color-secondary-foreground: #d4c7e1; + --color-secondary-border: #27242c; + --color-switch-active-track: #a3004c; + --color-switch-active-thumb: #fbd0e8; + --color-switch-inactive-track: #362d3d; + --color-switch-inactive-thumb: #e7d0dd; + --color-danger: #331a2b; + --color-danger-border: rgba(157, 23, 77, 0.32); + --color-danger-foreground: #fbd0e8; + --color-input: #2c2631; + --color-input-border: #302029; + --color-sidebar-search: #261922; + --color-placeholder: #968d9f; + --color-icon: #f9f8fb; + --color-icon-muted: #d4c7e1; + --color-icon-subtle: #e7d0dd; + --color-header: rgba(31, 26, 36, 0.97); + --color-header-border: #27242c; + --color-glass-surface: rgba(16, 10, 14, 0.74); + --color-glass-tint: rgba(16, 10, 14, 0.22); + --color-status-bar: #1f1a24; + --color-md-body: #f9f8fb; + --color-md-strong: #f9f8fb; + --color-md-link: #a3004c; + --color-md-blockquote-border: #27242c; + --color-md-blockquote-bg: #423a45; + --color-md-code-bg: #1f1a24; + --color-md-code-text: #d8c3ef; + --color-md-user-code-bg: rgba(242, 235, 250, 0.18); + --color-md-user-code-text: #f2ebfa; + --color-md-user-fence-bg: rgba(0, 0, 0, 0.28); + --color-md-user-fence-text: #f2ebfa; + --color-md-hr: #27242c; + --color-user-bubble: #2b2431; + --color-user-bubble-foreground: #f2ebfa; + --color-user-bubble-foreground-muted: rgba(242, 235, 250, 0.78); + --color-user-bubble-skill-foreground: #cb709a; + --color-backdrop: rgba(0, 0, 0, 0.48); + --color-drawer: rgba(23, 16, 24, 0.99); + --color-drawer-shadow: rgba(0, 0, 0, 0.32); + --color-dot-separator: rgba(231, 208, 221, 0.35); + --color-wordmark: #f9f8fb; + --color-chevron: rgba(231, 208, 221, 0.42); + --color-adaptive-amber-50-950-a40: oklch(27.9% 0.077 45.635 / 40%); + --color-adaptive-amber-200-900-a60: oklch(41.4% 0.112 45.904 / 60%); + --color-adaptive-amber-500-a12-a16: oklch(76.9% 0.188 70.08 / 16%); + --color-adaptive-amber-700-300: oklch(87.9% 0.169 91.605); + --color-adaptive-amber-700-400: oklch(82.8% 0.189 84.429); + --color-adaptive-amber-800-200: oklch(92.4% 0.12 95.746); + --color-adaptive-blue-50-blue-400-a14: oklch(70.7% 0.165 254.624 / 14%); + --color-adaptive-blue-300-a50-blue-400-a28: oklch(70.7% 0.165 254.624 / 28%); + --color-adaptive-blue-500-a20-blue-400-a15: oklch(70.7% 0.165 254.624 / 15%); + --color-adaptive-blue-500-400: oklch(70.7% 0.165 254.624); + --color-adaptive-blue-600-400: oklch(70.7% 0.165 254.624); + --color-adaptive-black-a10-a25: rgb(0 0 0 / 25%); + --color-adaptive-black-a15-a35: rgb(0 0 0 / 35%); + --color-adaptive-emerald-500-a12-a16: oklch(69.6% 0.17 162.48 / 16%); + --color-adaptive-emerald-600-400: oklch(76.5% 0.177 163.223); + --color-adaptive-emerald-700-300: oklch(84.5% 0.143 164.978); + --color-adaptive-indigo-500-a12-a16: oklch(58.5% 0.233 277.117 / 16%); + --color-adaptive-indigo-600-300: oklch(78.5% 0.115 274.713); + --color-adaptive-indigo-700-300: oklch(78.5% 0.115 274.713); + --color-adaptive-neutral-100-900: oklch(20.5% 0 0); + --color-adaptive-neutral-200-700-a60: oklch(37.1% 0 0 / 60%); + --color-adaptive-neutral-200-800: oklch(26.9% 0 0); + --color-adaptive-neutral-200-a70-white-a8: rgb(255 255 255 / 8%); + --color-adaptive-neutral-200-white-a6: rgb(255 255 255 / 6%); + --color-adaptive-neutral-200-white-a8: rgb(255 255 255 / 8%); + --color-adaptive-neutral-200-a80-white-a8: rgb(255 255 255 / 8%); + --color-adaptive-neutral-300-a60-white-a12: rgb(255 255 255 / 12%); + --color-adaptive-neutral-400-500: oklch(55.6% 0 0); + --color-adaptive-neutral-400-a60-500-a60: oklch(55.6% 0 0 / 60%); + --color-adaptive-neutral-400-a80-500-a80: oklch(55.6% 0 0 / 80%); + --color-adaptive-neutral-500-a10-a16: oklch(55.6% 0 0 / 16%); + --color-adaptive-neutral-500-400: oklch(70.8% 0 0); + --color-adaptive-neutral-500-500: oklch(55.6% 0 0); + --color-adaptive-neutral-600-300: oklch(87% 0 0); + --color-adaptive-neutral-600-400: oklch(70.8% 0 0); + --color-adaptive-neutral-950-50: oklch(98.5% 0 0); + --color-adaptive-red-50-950-a80: oklch(25.8% 0.092 26.042 / 80%); + --color-adaptive-red-200-800: oklch(44.4% 0.177 26.899); + --color-adaptive-red-600-a80-400-a80: oklch(70.4% 0.191 22.216 / 80%); + --color-adaptive-red-700-300: oklch(80.8% 0.114 19.571); + --color-adaptive-rose-100-500-a18: oklch(64.5% 0.246 16.439 / 18%); + --color-adaptive-rose-100-a80-500-a12: oklch(64.5% 0.246 16.439 / 12%); + --color-adaptive-rose-300-a70-400-a28: oklch(71.2% 0.194 13.428 / 28%); + --color-adaptive-rose-500-a12-a16: oklch(64.5% 0.246 16.439 / 16%); + --color-adaptive-rose-500-400: oklch(71.2% 0.194 13.428); + --color-adaptive-rose-600-400: oklch(71.2% 0.194 13.428); + --color-adaptive-rose-700-300: oklch(81% 0.117 11.638); + --color-adaptive-sky-500-a12-a16: oklch(68.5% 0.169 237.323 / 16%); + --color-adaptive-sky-600-400: oklch(74.6% 0.16 232.661); + --color-adaptive-sky-700-300: oklch(82.8% 0.111 230.318); + --color-adaptive-violet-500-a12-a16: oklch(60.6% 0.25 292.717 / 16%); + --color-adaptive-violet-600-400: oklch(70.2% 0.183 293.541); + --color-adaptive-violet-700-300: oklch(81.1% 0.111 293.571); + --color-adaptive-white-neutral-950-a70: oklch(14.5% 0 0 / 70%); + --color-adaptive-zinc-500-a12-a16: oklch(55.2% 0.016 285.938 / 16%); + --color-adaptive-zinc-500-400: oklch(70.5% 0.015 286.067); + --color-adaptive-zinc-600-300: oklch(87.1% 0.006 286.286); + } + + @variant grove-light { + --color-screen: #f3f7f4; + --color-sheet: rgba(243, 247, 244, 0.98); + --color-sheet-solid: #f3f7f4; + --color-card: #ecefed; + --color-card-alt: #f3f7f4; + --color-card-translucent: rgba(236, 239, 237, 0.8); + --color-foreground: #241523; + --color-foreground-secondary: #746c73; + --color-foreground-muted: #6e696f; + --color-foreground-tertiary: #746c73; + --color-border: #cbd5d1; + --color-border-subtle: rgba(203, 213, 209, 0.7); + --color-separator: rgba(203, 213, 209, 0.55); + --color-subtle: #e6f0ea; + --color-subtle-strong: #e2ede7; + --color-inline-skill-background: #d5e6dd; + --color-inline-skill-border: rgba(27, 125, 80, 0.42); + --color-inline-skill-foreground: #241523; + --color-primary: #1b7d50; + --color-primary-foreground: #fffaff; + --color-primary-shadow: #000000; + --color-secondary: #e2ede7; + --color-secondary-foreground: #241523; + --color-secondary-border: #cbd5d1; + --color-switch-active-track: #1b7d50; + --color-switch-active-thumb: #fffaff; + --color-switch-inactive-track: #e2ede7; + --color-switch-inactive-thumb: #6e696f; + --color-danger: #f4e7e5; + --color-danger-border: rgba(251, 44, 54, 0.32); + --color-danger-foreground: #c10007; + --color-input: #ecefed; + --color-input-border: #becbc5; + --color-sidebar-search: #d3dcd8; + --color-placeholder: #716971; + --color-icon: #241523; + --color-icon-muted: #746c73; + --color-icon-subtle: #746c73; + --color-header: rgba(243, 247, 244, 0.97); + --color-header-border: #d5e6dd; + --color-glass-surface: rgba(231, 233, 232, 0.74); + --color-glass-tint: rgba(231, 233, 232, 0.22); + --color-status-bar: #f3f7f4; + --color-md-body: #241523; + --color-md-strong: #241523; + --color-md-link: #1b7d50; + --color-md-blockquote-border: #cbd5d1; + --color-md-blockquote-bg: #e6f0ea; + --color-md-code-bg: #eef1ef; + --color-md-code-text: #241523; + --color-md-user-code-bg: rgba(36, 21, 35, 0.18); + --color-md-user-code-text: #241523; + --color-md-user-fence-bg: rgba(0, 0, 0, 0.16); + --color-md-user-fence-text: #241523; + --color-md-hr: #cbd5d1; + --color-user-bubble: #cce1d7; + --color-user-bubble-foreground: #241523; + --color-user-bubble-foreground-muted: rgba(36, 21, 35, 0.78); + --color-user-bubble-skill-foreground: #815a0e; + --color-backdrop: rgba(0, 0, 0, 0.22); + --color-drawer: rgba(226, 237, 231, 0.99); + --color-drawer-shadow: rgba(0, 0, 0, 0.12); + --color-dot-separator: rgba(116, 108, 115, 0.35); + --color-wordmark: #241523; + --color-chevron: rgba(116, 108, 115, 0.42); + --color-adaptive-amber-50-950-a40: oklch(98.7% 0.022 95.277); + --color-adaptive-amber-200-900-a60: oklch(92.4% 0.12 95.746); + --color-adaptive-amber-500-a12-a16: oklch(76.9% 0.188 70.08 / 12%); + --color-adaptive-amber-700-300: oklch(55.5% 0.163 48.998); + --color-adaptive-amber-700-400: oklch(55.5% 0.163 48.998); + --color-adaptive-amber-800-200: oklch(47.3% 0.137 46.201); + --color-adaptive-blue-50-blue-400-a14: oklch(97% 0.014 254.604); + --color-adaptive-blue-300-a50-blue-400-a28: oklch(80.9% 0.105 251.813 / 50%); + --color-adaptive-blue-500-a20-blue-400-a15: oklch(62.3% 0.214 259.815 / 20%); + --color-adaptive-blue-500-400: oklch(62.3% 0.214 259.815); + --color-adaptive-blue-600-400: oklch(54.6% 0.245 262.881); + --color-adaptive-black-a10-a25: rgb(0 0 0 / 10%); + --color-adaptive-black-a15-a35: rgb(0 0 0 / 15%); + --color-adaptive-emerald-500-a12-a16: oklch(69.6% 0.17 162.48 / 12%); + --color-adaptive-emerald-600-400: oklch(59.6% 0.145 163.225); + --color-adaptive-emerald-700-300: oklch(50.8% 0.118 165.612); + --color-adaptive-indigo-500-a12-a16: oklch(58.5% 0.233 277.117 / 12%); + --color-adaptive-indigo-600-300: oklch(51.1% 0.262 276.966); + --color-adaptive-indigo-700-300: oklch(45.7% 0.24 277.023); + --color-adaptive-neutral-100-900: oklch(97% 0 0); + --color-adaptive-neutral-200-700-a60: oklch(92.2% 0 0); + --color-adaptive-neutral-200-800: oklch(92.2% 0 0); + --color-adaptive-neutral-200-a70-white-a8: oklch(92.2% 0 0 / 70%); + --color-adaptive-neutral-200-white-a6: oklch(92.2% 0 0); + --color-adaptive-neutral-200-white-a8: oklch(92.2% 0 0); + --color-adaptive-neutral-200-a80-white-a8: oklch(92.2% 0 0 / 80%); + --color-adaptive-neutral-300-a60-white-a12: oklch(87% 0 0 / 60%); + --color-adaptive-neutral-400-500: oklch(70.8% 0 0); + --color-adaptive-neutral-400-a60-500-a60: oklch(70.8% 0 0 / 60%); + --color-adaptive-neutral-400-a80-500-a80: oklch(70.8% 0 0 / 80%); + --color-adaptive-neutral-500-a10-a16: oklch(55.6% 0 0 / 10%); + --color-adaptive-neutral-500-400: oklch(55.6% 0 0); + --color-adaptive-neutral-500-500: oklch(55.6% 0 0); + --color-adaptive-neutral-600-300: oklch(43.9% 0 0); + --color-adaptive-neutral-600-400: oklch(43.9% 0 0); + --color-adaptive-neutral-950-50: oklch(14.5% 0 0); + --color-adaptive-red-50-950-a80: oklch(97.1% 0.013 17.38); + --color-adaptive-red-200-800: oklch(88.5% 0.062 18.334); + --color-adaptive-red-600-a80-400-a80: oklch(57.7% 0.245 27.325 / 80%); + --color-adaptive-red-700-300: oklch(50.5% 0.213 27.518); + --color-adaptive-rose-100-500-a18: oklch(94.1% 0.03 12.58); + --color-adaptive-rose-100-a80-500-a12: oklch(94.1% 0.03 12.58 / 80%); + --color-adaptive-rose-300-a70-400-a28: oklch(81% 0.117 11.638 / 70%); + --color-adaptive-rose-500-a12-a16: oklch(64.5% 0.246 16.439 / 12%); + --color-adaptive-rose-500-400: oklch(64.5% 0.246 16.439); + --color-adaptive-rose-600-400: oklch(58.6% 0.253 17.585); + --color-adaptive-rose-700-300: oklch(51.4% 0.222 16.935); + --color-adaptive-sky-500-a12-a16: oklch(68.5% 0.169 237.323 / 12%); + --color-adaptive-sky-600-400: oklch(58.8% 0.158 241.966); + --color-adaptive-sky-700-300: oklch(50% 0.134 242.749); + --color-adaptive-violet-500-a12-a16: oklch(60.6% 0.25 292.717 / 12%); + --color-adaptive-violet-600-400: oklch(54.1% 0.281 293.009); + --color-adaptive-violet-700-300: oklch(49.1% 0.27 292.581); + --color-adaptive-white-neutral-950-a70: #fff; + --color-adaptive-zinc-500-a12-a16: oklch(55.2% 0.016 285.938 / 12%); + --color-adaptive-zinc-500-400: oklch(55.2% 0.016 285.938); + --color-adaptive-zinc-600-300: oklch(44.2% 0.017 285.786); + } + + @variant grove-dark { + --color-screen: #1b2821; + --color-sheet: rgba(27, 40, 33, 0.98); + --color-sheet-solid: #1b2821; + --color-card: #36413c; + --color-card-alt: #1b2821; + --color-card-translucent: rgba(54, 65, 60, 0.8); + --color-foreground: #fffaff; + --color-foreground-secondary: #919595; + --color-foreground-muted: #9da5a2; + --color-foreground-tertiary: #919595; + --color-border: #415f4f; + --color-border-subtle: rgba(65, 95, 79, 0.7); + --color-separator: rgba(65, 95, 79, 0.55); + --color-subtle: #253e31; + --color-subtle-strong: #2a4b39; + --color-inline-skill-background: #325c46; + --color-inline-skill-border: rgba(105, 214, 154, 0.42); + --color-inline-skill-foreground: #fffaff; + --color-primary: #69d69a; + --color-primary-foreground: #241523; + --color-primary-shadow: #000000; + --color-secondary: #2a4b39; + --color-secondary-foreground: #fffaff; + --color-secondary-border: #415f4f; + --color-switch-active-track: #69d69a; + --color-switch-active-thumb: #241523; + --color-switch-inactive-track: #2a4b39; + --color-switch-inactive-thumb: #9da5a2; + --color-danger: #3f2c28; + --color-danger-border: rgba(251, 65, 74, 0.32); + --color-danger-foreground: #ff6668; + --color-input: #36413c; + --color-input-border: #4f725f; + --color-sidebar-search: #45554d; + --color-placeholder: #a9abab; + --color-icon: #fffaff; + --color-icon-muted: #919595; + --color-icon-subtle: #919595; + --color-header: rgba(27, 40, 33, 0.97); + --color-header-border: #36654c; + --color-glass-surface: rgba(68, 77, 73, 0.74); + --color-glass-tint: rgba(68, 77, 73, 0.22); + --color-status-bar: #1b2821; + --color-md-body: #fffaff; + --color-md-strong: #fffaff; + --color-md-link: #69d69a; + --color-md-blockquote-border: #415f4f; + --color-md-blockquote-bg: #253e31; + --color-md-code-bg: #28342e; + --color-md-code-text: #fffaff; + --color-md-user-code-bg: rgba(255, 250, 255, 0.18); + --color-md-user-code-text: #fffaff; + --color-md-user-fence-bg: rgba(0, 0, 0, 0.28); + --color-md-user-fence-text: #fffaff; + --color-md-hr: #415f4f; + --color-user-bubble: #37664d; + --color-user-bubble-foreground: #fffaff; + --color-user-bubble-foreground-muted: rgba(255, 250, 255, 0.78); + --color-user-bubble-skill-foreground: #eed295; + --color-backdrop: rgba(0, 0, 0, 0.48); + --color-drawer: rgba(33, 54, 43, 0.99); + --color-drawer-shadow: rgba(0, 0, 0, 0.32); + --color-dot-separator: rgba(145, 149, 149, 0.35); + --color-wordmark: #fffaff; + --color-chevron: rgba(145, 149, 149, 0.42); + --color-adaptive-amber-50-950-a40: oklch(27.9% 0.077 45.635 / 40%); + --color-adaptive-amber-200-900-a60: oklch(41.4% 0.112 45.904 / 60%); + --color-adaptive-amber-500-a12-a16: oklch(76.9% 0.188 70.08 / 16%); + --color-adaptive-amber-700-300: oklch(87.9% 0.169 91.605); + --color-adaptive-amber-700-400: oklch(82.8% 0.189 84.429); + --color-adaptive-amber-800-200: oklch(92.4% 0.12 95.746); + --color-adaptive-blue-50-blue-400-a14: oklch(70.7% 0.165 254.624 / 14%); + --color-adaptive-blue-300-a50-blue-400-a28: oklch(70.7% 0.165 254.624 / 28%); + --color-adaptive-blue-500-a20-blue-400-a15: oklch(70.7% 0.165 254.624 / 15%); + --color-adaptive-blue-500-400: oklch(70.7% 0.165 254.624); + --color-adaptive-blue-600-400: oklch(70.7% 0.165 254.624); + --color-adaptive-black-a10-a25: rgb(0 0 0 / 25%); + --color-adaptive-black-a15-a35: rgb(0 0 0 / 35%); + --color-adaptive-emerald-500-a12-a16: oklch(69.6% 0.17 162.48 / 16%); + --color-adaptive-emerald-600-400: oklch(76.5% 0.177 163.223); + --color-adaptive-emerald-700-300: oklch(84.5% 0.143 164.978); + --color-adaptive-indigo-500-a12-a16: oklch(58.5% 0.233 277.117 / 16%); + --color-adaptive-indigo-600-300: oklch(78.5% 0.115 274.713); + --color-adaptive-indigo-700-300: oklch(78.5% 0.115 274.713); + --color-adaptive-neutral-100-900: oklch(20.5% 0 0); + --color-adaptive-neutral-200-700-a60: oklch(37.1% 0 0 / 60%); + --color-adaptive-neutral-200-800: oklch(26.9% 0 0); + --color-adaptive-neutral-200-a70-white-a8: rgb(255 255 255 / 8%); + --color-adaptive-neutral-200-white-a6: rgb(255 255 255 / 6%); + --color-adaptive-neutral-200-white-a8: rgb(255 255 255 / 8%); + --color-adaptive-neutral-200-a80-white-a8: rgb(255 255 255 / 8%); + --color-adaptive-neutral-300-a60-white-a12: rgb(255 255 255 / 12%); + --color-adaptive-neutral-400-500: oklch(55.6% 0 0); + --color-adaptive-neutral-400-a60-500-a60: oklch(55.6% 0 0 / 60%); + --color-adaptive-neutral-400-a80-500-a80: oklch(55.6% 0 0 / 80%); + --color-adaptive-neutral-500-a10-a16: oklch(55.6% 0 0 / 16%); + --color-adaptive-neutral-500-400: oklch(70.8% 0 0); + --color-adaptive-neutral-500-500: oklch(55.6% 0 0); + --color-adaptive-neutral-600-300: oklch(87% 0 0); + --color-adaptive-neutral-600-400: oklch(70.8% 0 0); + --color-adaptive-neutral-950-50: oklch(98.5% 0 0); + --color-adaptive-red-50-950-a80: oklch(25.8% 0.092 26.042 / 80%); + --color-adaptive-red-200-800: oklch(44.4% 0.177 26.899); + --color-adaptive-red-600-a80-400-a80: oklch(70.4% 0.191 22.216 / 80%); + --color-adaptive-red-700-300: oklch(80.8% 0.114 19.571); + --color-adaptive-rose-100-500-a18: oklch(64.5% 0.246 16.439 / 18%); + --color-adaptive-rose-100-a80-500-a12: oklch(64.5% 0.246 16.439 / 12%); + --color-adaptive-rose-300-a70-400-a28: oklch(71.2% 0.194 13.428 / 28%); + --color-adaptive-rose-500-a12-a16: oklch(64.5% 0.246 16.439 / 16%); + --color-adaptive-rose-500-400: oklch(71.2% 0.194 13.428); + --color-adaptive-rose-600-400: oklch(71.2% 0.194 13.428); + --color-adaptive-rose-700-300: oklch(81% 0.117 11.638); + --color-adaptive-sky-500-a12-a16: oklch(68.5% 0.169 237.323 / 16%); + --color-adaptive-sky-600-400: oklch(74.6% 0.16 232.661); + --color-adaptive-sky-700-300: oklch(82.8% 0.111 230.318); + --color-adaptive-violet-500-a12-a16: oklch(60.6% 0.25 292.717 / 16%); + --color-adaptive-violet-600-400: oklch(70.2% 0.183 293.541); + --color-adaptive-violet-700-300: oklch(81.1% 0.111 293.571); + --color-adaptive-white-neutral-950-a70: oklch(14.5% 0 0 / 70%); + --color-adaptive-zinc-500-a12-a16: oklch(55.2% 0.016 285.938 / 16%); + --color-adaptive-zinc-500-400: oklch(70.5% 0.015 286.067); + --color-adaptive-zinc-600-300: oklch(87.1% 0.006 286.286); + } + + @variant ocean-light { + --color-screen: #f5f7f8; + --color-sheet: rgba(245, 247, 248, 0.98); + --color-sheet-solid: #f5f7f8; + --color-card: #edeff1; + --color-card-alt: #f5f7f8; + --color-card-translucent: rgba(237, 239, 241, 0.8); + --color-foreground: #241523; + --color-foreground-secondary: #746c75; + --color-foreground-muted: #6f6873; + --color-foreground-tertiary: #746c75; + --color-border: #cdd4dc; + --color-border-subtle: rgba(205, 212, 220, 0.7); + --color-separator: rgba(205, 212, 220, 0.55); + --color-subtle: #e8eff4; + --color-subtle-strong: #e4ecf2; + --color-inline-skill-background: #d8e4ee; + --color-inline-skill-border: rgba(38, 114, 175, 0.42); + --color-inline-skill-foreground: #241523; + --color-primary: #2672af; + --color-primary-foreground: #fffaff; + --color-primary-shadow: #000000; + --color-secondary: #e4ecf2; + --color-secondary-foreground: #241523; + --color-secondary-border: #cdd4dc; + --color-switch-active-track: #2672af; + --color-switch-active-thumb: #fffaff; + --color-switch-inactive-track: #e4ecf2; + --color-switch-inactive-thumb: #6f6873; + --color-danger: #f5e6e9; + --color-danger-border: rgba(251, 44, 54, 0.32); + --color-danger-foreground: #c10007; + --color-input: #edeff1; + --color-input-border: #c0c9d4; + --color-sidebar-search: #d5dbe2; + --color-placeholder: #716972; + --color-icon: #241523; + --color-icon-muted: #746c75; + --color-icon-subtle: #746c75; + --color-header: rgba(245, 247, 248, 0.97); + --color-header-border: #d8e4ee; + --color-glass-surface: rgba(232, 233, 235, 0.74); + --color-glass-tint: rgba(232, 233, 235, 0.22); + --color-status-bar: #f5f7f8; + --color-md-body: #241523; + --color-md-strong: #241523; + --color-md-link: #2672af; + --color-md-blockquote-border: #cdd4dc; + --color-md-blockquote-bg: #e8eff4; + --color-md-code-bg: #f0f1f3; + --color-md-code-text: #241523; + --color-md-user-code-bg: rgba(36, 21, 35, 0.18); + --color-md-user-code-text: #241523; + --color-md-user-fence-bg: rgba(0, 0, 0, 0.16); + --color-md-user-fence-text: #241523; + --color-md-hr: #cdd4dc; + --color-user-bubble: #d0dfeb; + --color-user-bubble-foreground: #241523; + --color-user-bubble-foreground-muted: rgba(36, 21, 35, 0.78); + --color-user-bubble-skill-foreground: #0a6c72; + --color-backdrop: rgba(0, 0, 0, 0.22); + --color-drawer: rgba(228, 236, 242, 0.99); + --color-drawer-shadow: rgba(0, 0, 0, 0.12); + --color-dot-separator: rgba(116, 108, 117, 0.35); + --color-wordmark: #241523; + --color-chevron: rgba(116, 108, 117, 0.42); + --color-adaptive-amber-50-950-a40: oklch(98.7% 0.022 95.277); + --color-adaptive-amber-200-900-a60: oklch(92.4% 0.12 95.746); + --color-adaptive-amber-500-a12-a16: oklch(76.9% 0.188 70.08 / 12%); + --color-adaptive-amber-700-300: oklch(55.5% 0.163 48.998); + --color-adaptive-amber-700-400: oklch(55.5% 0.163 48.998); + --color-adaptive-amber-800-200: oklch(47.3% 0.137 46.201); + --color-adaptive-blue-50-blue-400-a14: oklch(97% 0.014 254.604); + --color-adaptive-blue-300-a50-blue-400-a28: oklch(80.9% 0.105 251.813 / 50%); + --color-adaptive-blue-500-a20-blue-400-a15: oklch(62.3% 0.214 259.815 / 20%); + --color-adaptive-blue-500-400: oklch(62.3% 0.214 259.815); + --color-adaptive-blue-600-400: oklch(54.6% 0.245 262.881); + --color-adaptive-black-a10-a25: rgb(0 0 0 / 10%); + --color-adaptive-black-a15-a35: rgb(0 0 0 / 15%); + --color-adaptive-emerald-500-a12-a16: oklch(69.6% 0.17 162.48 / 12%); + --color-adaptive-emerald-600-400: oklch(59.6% 0.145 163.225); + --color-adaptive-emerald-700-300: oklch(50.8% 0.118 165.612); + --color-adaptive-indigo-500-a12-a16: oklch(58.5% 0.233 277.117 / 12%); + --color-adaptive-indigo-600-300: oklch(51.1% 0.262 276.966); + --color-adaptive-indigo-700-300: oklch(45.7% 0.24 277.023); + --color-adaptive-neutral-100-900: oklch(97% 0 0); + --color-adaptive-neutral-200-700-a60: oklch(92.2% 0 0); + --color-adaptive-neutral-200-800: oklch(92.2% 0 0); + --color-adaptive-neutral-200-a70-white-a8: oklch(92.2% 0 0 / 70%); + --color-adaptive-neutral-200-white-a6: oklch(92.2% 0 0); + --color-adaptive-neutral-200-white-a8: oklch(92.2% 0 0); + --color-adaptive-neutral-200-a80-white-a8: oklch(92.2% 0 0 / 80%); + --color-adaptive-neutral-300-a60-white-a12: oklch(87% 0 0 / 60%); + --color-adaptive-neutral-400-500: oklch(70.8% 0 0); + --color-adaptive-neutral-400-a60-500-a60: oklch(70.8% 0 0 / 60%); + --color-adaptive-neutral-400-a80-500-a80: oklch(70.8% 0 0 / 80%); + --color-adaptive-neutral-500-a10-a16: oklch(55.6% 0 0 / 10%); + --color-adaptive-neutral-500-400: oklch(55.6% 0 0); + --color-adaptive-neutral-500-500: oklch(55.6% 0 0); + --color-adaptive-neutral-600-300: oklch(43.9% 0 0); + --color-adaptive-neutral-600-400: oklch(43.9% 0 0); + --color-adaptive-neutral-950-50: oklch(14.5% 0 0); + --color-adaptive-red-50-950-a80: oklch(97.1% 0.013 17.38); + --color-adaptive-red-200-800: oklch(88.5% 0.062 18.334); + --color-adaptive-red-600-a80-400-a80: oklch(57.7% 0.245 27.325 / 80%); + --color-adaptive-red-700-300: oklch(50.5% 0.213 27.518); + --color-adaptive-rose-100-500-a18: oklch(94.1% 0.03 12.58); + --color-adaptive-rose-100-a80-500-a12: oklch(94.1% 0.03 12.58 / 80%); + --color-adaptive-rose-300-a70-400-a28: oklch(81% 0.117 11.638 / 70%); + --color-adaptive-rose-500-a12-a16: oklch(64.5% 0.246 16.439 / 12%); + --color-adaptive-rose-500-400: oklch(64.5% 0.246 16.439); + --color-adaptive-rose-600-400: oklch(58.6% 0.253 17.585); + --color-adaptive-rose-700-300: oklch(51.4% 0.222 16.935); + --color-adaptive-sky-500-a12-a16: oklch(68.5% 0.169 237.323 / 12%); + --color-adaptive-sky-600-400: oklch(58.8% 0.158 241.966); + --color-adaptive-sky-700-300: oklch(50% 0.134 242.749); + --color-adaptive-violet-500-a12-a16: oklch(60.6% 0.25 292.717 / 12%); + --color-adaptive-violet-600-400: oklch(54.1% 0.281 293.009); + --color-adaptive-violet-700-300: oklch(49.1% 0.27 292.581); + --color-adaptive-white-neutral-950-a70: #fff; + --color-adaptive-zinc-500-a12-a16: oklch(55.2% 0.016 285.938 / 12%); + --color-adaptive-zinc-500-400: oklch(55.2% 0.016 285.938); + --color-adaptive-zinc-600-300: oklch(44.2% 0.017 285.786); + } + + @variant ocean-dark { + --color-screen: #17212b; + --color-sheet: rgba(23, 33, 43, 0.98); + --color-sheet-solid: #17212b; + --color-card: #333b45; + --color-card-alt: #17212b; + --color-card-translucent: rgba(51, 59, 69, 0.8); + --color-foreground: #fffaff; + --color-foreground-secondary: #8d8f97; + --color-foreground-muted: #969ca6; + --color-foreground-tertiary: #8d8f97; + --color-border: #405567; + --color-border-subtle: rgba(64, 85, 103, 0.7); + --color-separator: rgba(64, 85, 103, 0.55); + --color-subtle: #233544; + --color-subtle-strong: #293f52; + --color-inline-skill-background: #324e66; + --color-inline-skill-border: rgba(112, 185, 238, 0.42); + --color-inline-skill-foreground: #fffaff; + --color-primary: #70b9ee; + --color-primary-foreground: #241523; + --color-primary-shadow: #000000; + --color-secondary: #293f52; + --color-secondary-foreground: #fffaff; + --color-secondary-border: #405567; + --color-switch-active-track: #70b9ee; + --color-switch-active-thumb: #241523; + --color-switch-inactive-track: #293f52; + --color-switch-inactive-thumb: #969ca6; + --color-danger: #3c2630; + --color-danger-border: rgba(251, 65, 74, 0.32); + --color-danger-foreground: #ff6467; + --color-input: #333b45; + --color-input-border: #4f677b; + --color-sidebar-search: #424e5a; + --color-placeholder: #a4a4ac; + --color-icon: #fffaff; + --color-icon-muted: #8d8f97; + --color-icon-subtle: #8d8f97; + --color-header: rgba(23, 33, 43, 0.97); + --color-header-border: #36566f; + --color-glass-surface: rgba(65, 72, 81, 0.74); + --color-glass-tint: rgba(65, 72, 81, 0.22); + --color-status-bar: #17212b; + --color-md-body: #fffaff; + --color-md-strong: #fffaff; + --color-md-link: #70b9ee; + --color-md-blockquote-border: #405567; + --color-md-blockquote-bg: #233544; + --color-md-code-bg: #252e38; + --color-md-code-text: #fffaff; + --color-md-user-code-bg: rgba(255, 250, 255, 0.18); + --color-md-user-code-text: #fffaff; + --color-md-user-fence-bg: rgba(0, 0, 0, 0.28); + --color-md-user-fence-text: #fffaff; + --color-md-hr: #405567; + --color-user-bubble: #375871; + --color-user-bubble-foreground: #fffaff; + --color-user-bubble-foreground-muted: rgba(255, 250, 255, 0.78); + --color-user-bubble-skill-foreground: #75d8dd; + --color-backdrop: rgba(0, 0, 0, 0.48); + --color-drawer: rgba(30, 45, 59, 0.99); + --color-drawer-shadow: rgba(0, 0, 0, 0.32); + --color-dot-separator: rgba(141, 143, 151, 0.35); + --color-wordmark: #fffaff; + --color-chevron: rgba(141, 143, 151, 0.42); + --color-adaptive-amber-50-950-a40: oklch(27.9% 0.077 45.635 / 40%); + --color-adaptive-amber-200-900-a60: oklch(41.4% 0.112 45.904 / 60%); + --color-adaptive-amber-500-a12-a16: oklch(76.9% 0.188 70.08 / 16%); + --color-adaptive-amber-700-300: oklch(87.9% 0.169 91.605); + --color-adaptive-amber-700-400: oklch(82.8% 0.189 84.429); + --color-adaptive-amber-800-200: oklch(92.4% 0.12 95.746); + --color-adaptive-blue-50-blue-400-a14: oklch(70.7% 0.165 254.624 / 14%); + --color-adaptive-blue-300-a50-blue-400-a28: oklch(70.7% 0.165 254.624 / 28%); + --color-adaptive-blue-500-a20-blue-400-a15: oklch(70.7% 0.165 254.624 / 15%); + --color-adaptive-blue-500-400: oklch(70.7% 0.165 254.624); + --color-adaptive-blue-600-400: oklch(70.7% 0.165 254.624); + --color-adaptive-black-a10-a25: rgb(0 0 0 / 25%); + --color-adaptive-black-a15-a35: rgb(0 0 0 / 35%); + --color-adaptive-emerald-500-a12-a16: oklch(69.6% 0.17 162.48 / 16%); + --color-adaptive-emerald-600-400: oklch(76.5% 0.177 163.223); + --color-adaptive-emerald-700-300: oklch(84.5% 0.143 164.978); + --color-adaptive-indigo-500-a12-a16: oklch(58.5% 0.233 277.117 / 16%); + --color-adaptive-indigo-600-300: oklch(78.5% 0.115 274.713); + --color-adaptive-indigo-700-300: oklch(78.5% 0.115 274.713); + --color-adaptive-neutral-100-900: oklch(20.5% 0 0); + --color-adaptive-neutral-200-700-a60: oklch(37.1% 0 0 / 60%); + --color-adaptive-neutral-200-800: oklch(26.9% 0 0); + --color-adaptive-neutral-200-a70-white-a8: rgb(255 255 255 / 8%); + --color-adaptive-neutral-200-white-a6: rgb(255 255 255 / 6%); + --color-adaptive-neutral-200-white-a8: rgb(255 255 255 / 8%); + --color-adaptive-neutral-200-a80-white-a8: rgb(255 255 255 / 8%); + --color-adaptive-neutral-300-a60-white-a12: rgb(255 255 255 / 12%); + --color-adaptive-neutral-400-500: oklch(55.6% 0 0); + --color-adaptive-neutral-400-a60-500-a60: oklch(55.6% 0 0 / 60%); + --color-adaptive-neutral-400-a80-500-a80: oklch(55.6% 0 0 / 80%); + --color-adaptive-neutral-500-a10-a16: oklch(55.6% 0 0 / 16%); + --color-adaptive-neutral-500-400: oklch(70.8% 0 0); + --color-adaptive-neutral-500-500: oklch(55.6% 0 0); + --color-adaptive-neutral-600-300: oklch(87% 0 0); + --color-adaptive-neutral-600-400: oklch(70.8% 0 0); + --color-adaptive-neutral-950-50: oklch(98.5% 0 0); + --color-adaptive-red-50-950-a80: oklch(25.8% 0.092 26.042 / 80%); + --color-adaptive-red-200-800: oklch(44.4% 0.177 26.899); + --color-adaptive-red-600-a80-400-a80: oklch(70.4% 0.191 22.216 / 80%); + --color-adaptive-red-700-300: oklch(80.8% 0.114 19.571); + --color-adaptive-rose-100-500-a18: oklch(64.5% 0.246 16.439 / 18%); + --color-adaptive-rose-100-a80-500-a12: oklch(64.5% 0.246 16.439 / 12%); + --color-adaptive-rose-300-a70-400-a28: oklch(71.2% 0.194 13.428 / 28%); + --color-adaptive-rose-500-a12-a16: oklch(64.5% 0.246 16.439 / 16%); + --color-adaptive-rose-500-400: oklch(71.2% 0.194 13.428); + --color-adaptive-rose-600-400: oklch(71.2% 0.194 13.428); + --color-adaptive-rose-700-300: oklch(81% 0.117 11.638); + --color-adaptive-sky-500-a12-a16: oklch(68.5% 0.169 237.323 / 16%); + --color-adaptive-sky-600-400: oklch(74.6% 0.16 232.661); + --color-adaptive-sky-700-300: oklch(82.8% 0.111 230.318); + --color-adaptive-violet-500-a12-a16: oklch(60.6% 0.25 292.717 / 16%); + --color-adaptive-violet-600-400: oklch(70.2% 0.183 293.541); + --color-adaptive-violet-700-300: oklch(81.1% 0.111 293.571); + --color-adaptive-white-neutral-950-a70: oklch(14.5% 0 0 / 70%); + --color-adaptive-zinc-500-a12-a16: oklch(55.2% 0.016 285.938 / 16%); + --color-adaptive-zinc-500-400: oklch(70.5% 0.015 286.067); + --color-adaptive-zinc-600-300: oklch(87.1% 0.006 286.286); + } + + @variant ember-light { + --color-screen: #f9f7f5; + --color-sheet: rgba(249, 247, 245, 0.98); + --color-sheet-solid: #f9f7f5; + --color-card: #f1efee; + --color-card-alt: #f9f7f5; + --color-card-translucent: rgba(241, 239, 238, 0.8); + --color-foreground: #241523; + --color-foreground-secondary: #766c74; + --color-foreground-muted: #74686f; + --color-foreground-tertiary: #766c74; + --color-border: #ddd2ce; + --color-border-subtle: rgba(221, 210, 206, 0.7); + --color-separator: rgba(221, 210, 206, 0.55); + --color-subtle: #f4ede9; + --color-subtle-strong: #f3eae5; + --color-inline-skill-background: #eee0d9; + --color-inline-skill-border: rgba(174, 85, 42, 0.42); + --color-inline-skill-foreground: #241523; + --color-primary: #ae552a; + --color-primary-foreground: #fffaff; + --color-primary-shadow: #000000; + --color-secondary: #f3eae5; + --color-secondary-foreground: #241523; + --color-secondary-border: #ddd2ce; + --color-switch-active-track: #ae552a; + --color-switch-active-thumb: #fffaff; + --color-switch-inactive-track: #f3eae5; + --color-switch-inactive-thumb: #74686f; + --color-danger: #f9e7e6; + --color-danger-border: rgba(251, 44, 54, 0.32); + --color-danger-foreground: #c10007; + --color-input: #f1efee; + --color-input-border: #d4c6c1; + --color-sidebar-search: #e2d9d6; + --color-placeholder: #736971; + --color-icon: #241523; + --color-icon-muted: #766c74; + --color-icon-subtle: #766c74; + --color-header: rgba(249, 247, 245, 0.97); + --color-header-border: #eee0d9; + --color-glass-surface: rgba(236, 233, 233, 0.74); + --color-glass-tint: rgba(236, 233, 233, 0.22); + --color-status-bar: #f9f7f5; + --color-md-body: #241523; + --color-md-strong: #241523; + --color-md-link: #ae552a; + --color-md-blockquote-border: #ddd2ce; + --color-md-blockquote-bg: #f4ede9; + --color-md-code-bg: #f3f1f0; + --color-md-code-text: #241523; + --color-md-user-code-bg: rgba(36, 21, 35, 0.18); + --color-md-user-code-text: #241523; + --color-md-user-fence-bg: rgba(0, 0, 0, 0.16); + --color-md-user-fence-text: #241523; + --color-md-hr: #ddd2ce; + --color-user-bubble: #ebdad1; + --color-user-bubble-foreground: #241523; + --color-user-bubble-foreground-muted: rgba(36, 21, 35, 0.78); + --color-user-bubble-skill-foreground: #b13535; + --color-backdrop: rgba(0, 0, 0, 0.22); + --color-drawer: rgba(243, 234, 229, 0.99); + --color-drawer-shadow: rgba(0, 0, 0, 0.12); + --color-dot-separator: rgba(118, 108, 116, 0.35); + --color-wordmark: #241523; + --color-chevron: rgba(118, 108, 116, 0.42); + --color-adaptive-amber-50-950-a40: oklch(98.7% 0.022 95.277); + --color-adaptive-amber-200-900-a60: oklch(92.4% 0.12 95.746); + --color-adaptive-amber-500-a12-a16: oklch(76.9% 0.188 70.08 / 12%); + --color-adaptive-amber-700-300: oklch(55.5% 0.163 48.998); + --color-adaptive-amber-700-400: oklch(55.5% 0.163 48.998); + --color-adaptive-amber-800-200: oklch(47.3% 0.137 46.201); + --color-adaptive-blue-50-blue-400-a14: oklch(97% 0.014 254.604); + --color-adaptive-blue-300-a50-blue-400-a28: oklch(80.9% 0.105 251.813 / 50%); + --color-adaptive-blue-500-a20-blue-400-a15: oklch(62.3% 0.214 259.815 / 20%); + --color-adaptive-blue-500-400: oklch(62.3% 0.214 259.815); + --color-adaptive-blue-600-400: oklch(54.6% 0.245 262.881); + --color-adaptive-black-a10-a25: rgb(0 0 0 / 10%); + --color-adaptive-black-a15-a35: rgb(0 0 0 / 15%); + --color-adaptive-emerald-500-a12-a16: oklch(69.6% 0.17 162.48 / 12%); + --color-adaptive-emerald-600-400: oklch(59.6% 0.145 163.225); + --color-adaptive-emerald-700-300: oklch(50.8% 0.118 165.612); + --color-adaptive-indigo-500-a12-a16: oklch(58.5% 0.233 277.117 / 12%); + --color-adaptive-indigo-600-300: oklch(51.1% 0.262 276.966); + --color-adaptive-indigo-700-300: oklch(45.7% 0.24 277.023); + --color-adaptive-neutral-100-900: oklch(97% 0 0); + --color-adaptive-neutral-200-700-a60: oklch(92.2% 0 0); + --color-adaptive-neutral-200-800: oklch(92.2% 0 0); + --color-adaptive-neutral-200-a70-white-a8: oklch(92.2% 0 0 / 70%); + --color-adaptive-neutral-200-white-a6: oklch(92.2% 0 0); + --color-adaptive-neutral-200-white-a8: oklch(92.2% 0 0); + --color-adaptive-neutral-200-a80-white-a8: oklch(92.2% 0 0 / 80%); + --color-adaptive-neutral-300-a60-white-a12: oklch(87% 0 0 / 60%); + --color-adaptive-neutral-400-500: oklch(70.8% 0 0); + --color-adaptive-neutral-400-a60-500-a60: oklch(70.8% 0 0 / 60%); + --color-adaptive-neutral-400-a80-500-a80: oklch(70.8% 0 0 / 80%); + --color-adaptive-neutral-500-a10-a16: oklch(55.6% 0 0 / 10%); + --color-adaptive-neutral-500-400: oklch(55.6% 0 0); + --color-adaptive-neutral-500-500: oklch(55.6% 0 0); + --color-adaptive-neutral-600-300: oklch(43.9% 0 0); + --color-adaptive-neutral-600-400: oklch(43.9% 0 0); + --color-adaptive-neutral-950-50: oklch(14.5% 0 0); + --color-adaptive-red-50-950-a80: oklch(97.1% 0.013 17.38); + --color-adaptive-red-200-800: oklch(88.5% 0.062 18.334); + --color-adaptive-red-600-a80-400-a80: oklch(57.7% 0.245 27.325 / 80%); + --color-adaptive-red-700-300: oklch(50.5% 0.213 27.518); + --color-adaptive-rose-100-500-a18: oklch(94.1% 0.03 12.58); + --color-adaptive-rose-100-a80-500-a12: oklch(94.1% 0.03 12.58 / 80%); + --color-adaptive-rose-300-a70-400-a28: oklch(81% 0.117 11.638 / 70%); + --color-adaptive-rose-500-a12-a16: oklch(64.5% 0.246 16.439 / 12%); + --color-adaptive-rose-500-400: oklch(64.5% 0.246 16.439); + --color-adaptive-rose-600-400: oklch(58.6% 0.253 17.585); + --color-adaptive-rose-700-300: oklch(51.4% 0.222 16.935); + --color-adaptive-sky-500-a12-a16: oklch(68.5% 0.169 237.323 / 12%); + --color-adaptive-sky-600-400: oklch(58.8% 0.158 241.966); + --color-adaptive-sky-700-300: oklch(50% 0.134 242.749); + --color-adaptive-violet-500-a12-a16: oklch(60.6% 0.25 292.717 / 12%); + --color-adaptive-violet-600-400: oklch(54.1% 0.281 293.009); + --color-adaptive-violet-700-300: oklch(49.1% 0.27 292.581); + --color-adaptive-white-neutral-950-a70: #fff; + --color-adaptive-zinc-500-a12-a16: oklch(55.2% 0.016 285.938 / 12%); + --color-adaptive-zinc-500-400: oklch(55.2% 0.016 285.938); + --color-adaptive-zinc-600-300: oklch(44.2% 0.017 285.786); + } + + @variant ember-dark { + --color-screen: #291e1a; + --color-sheet: rgba(41, 30, 26, 0.98); + --color-sheet-solid: #291e1a; + --color-card: #433835; + --color-card-alt: #291e1a; + --color-card-translucent: rgba(67, 56, 53, 0.8); + --color-foreground: #fffaff; + --color-foreground-secondary: #968e8f; + --color-foreground-muted: #a59996; + --color-foreground-tertiary: #968e8f; + --color-border: #664c3f; + --color-border-subtle: rgba(102, 76, 63, 0.7); + --color-separator: rgba(102, 76, 63, 0.55); + --color-subtle: #432e23; + --color-subtle-strong: #513728; + --color-inline-skill-background: #644330; + --color-inline-skill-border: rgba(240, 154, 100, 0.42); + --color-inline-skill-foreground: #fffaff; + --color-primary: #f09a64; + --color-primary-foreground: #241523; + --color-primary-shadow: #000000; + --color-secondary: #513728; + --color-secondary-foreground: #fffaff; + --color-secondary-border: #664c3f; + --color-switch-active-track: #f09a64; + --color-switch-active-thumb: #241523; + --color-switch-inactive-track: #513728; + --color-switch-inactive-thumb: #a59996; + --color-danger: #4a2321; + --color-danger-border: rgba(251, 65, 74, 0.32); + --color-danger-foreground: #ff6467; + --color-input: #433835; + --color-input-border: #7a5d4d; + --color-sidebar-search: #584943; + --color-placeholder: #aba3a5; + --color-icon: #fffaff; + --color-icon-muted: #968e8f; + --color-icon-subtle: #968e8f; + --color-header: rgba(41, 30, 26, 0.97); + --color-header-border: #6e4934; + --color-glass-surface: rgba(79, 69, 67, 0.74); + --color-glass-tint: rgba(79, 69, 67, 0.22); + --color-status-bar: #291e1a; + --color-md-body: #fffaff; + --color-md-strong: #fffaff; + --color-md-link: #f09a64; + --color-md-blockquote-border: #664c3f; + --color-md-blockquote-bg: #432e23; + --color-md-code-bg: #362b27; + --color-md-code-text: #fffaff; + --color-md-user-code-bg: rgba(255, 250, 255, 0.18); + --color-md-user-code-text: #fffaff; + --color-md-user-fence-bg: rgba(0, 0, 0, 0.28); + --color-md-user-fence-text: #fffaff; + --color-md-hr: #664c3f; + --color-user-bubble: #704b34; + --color-user-bubble-foreground: #fffaff; + --color-user-bubble-foreground-muted: rgba(255, 250, 255, 0.78); + --color-user-bubble-skill-foreground: #fab6ad; + --color-backdrop: rgba(0, 0, 0, 0.48); + --color-drawer: rgba(57, 40, 31, 0.99); + --color-drawer-shadow: rgba(0, 0, 0, 0.32); + --color-dot-separator: rgba(150, 142, 143, 0.35); + --color-wordmark: #fffaff; + --color-chevron: rgba(150, 142, 143, 0.42); + --color-adaptive-amber-50-950-a40: oklch(27.9% 0.077 45.635 / 40%); + --color-adaptive-amber-200-900-a60: oklch(41.4% 0.112 45.904 / 60%); + --color-adaptive-amber-500-a12-a16: oklch(76.9% 0.188 70.08 / 16%); + --color-adaptive-amber-700-300: oklch(87.9% 0.169 91.605); + --color-adaptive-amber-700-400: oklch(82.8% 0.189 84.429); + --color-adaptive-amber-800-200: oklch(92.4% 0.12 95.746); + --color-adaptive-blue-50-blue-400-a14: oklch(70.7% 0.165 254.624 / 14%); + --color-adaptive-blue-300-a50-blue-400-a28: oklch(70.7% 0.165 254.624 / 28%); + --color-adaptive-blue-500-a20-blue-400-a15: oklch(70.7% 0.165 254.624 / 15%); + --color-adaptive-blue-500-400: oklch(70.7% 0.165 254.624); + --color-adaptive-blue-600-400: oklch(70.7% 0.165 254.624); + --color-adaptive-black-a10-a25: rgb(0 0 0 / 25%); + --color-adaptive-black-a15-a35: rgb(0 0 0 / 35%); + --color-adaptive-emerald-500-a12-a16: oklch(69.6% 0.17 162.48 / 16%); + --color-adaptive-emerald-600-400: oklch(76.5% 0.177 163.223); + --color-adaptive-emerald-700-300: oklch(84.5% 0.143 164.978); + --color-adaptive-indigo-500-a12-a16: oklch(58.5% 0.233 277.117 / 16%); + --color-adaptive-indigo-600-300: oklch(78.5% 0.115 274.713); + --color-adaptive-indigo-700-300: oklch(78.5% 0.115 274.713); + --color-adaptive-neutral-100-900: oklch(20.5% 0 0); + --color-adaptive-neutral-200-700-a60: oklch(37.1% 0 0 / 60%); + --color-adaptive-neutral-200-800: oklch(26.9% 0 0); + --color-adaptive-neutral-200-a70-white-a8: rgb(255 255 255 / 8%); + --color-adaptive-neutral-200-white-a6: rgb(255 255 255 / 6%); + --color-adaptive-neutral-200-white-a8: rgb(255 255 255 / 8%); + --color-adaptive-neutral-200-a80-white-a8: rgb(255 255 255 / 8%); + --color-adaptive-neutral-300-a60-white-a12: rgb(255 255 255 / 12%); + --color-adaptive-neutral-400-500: oklch(55.6% 0 0); + --color-adaptive-neutral-400-a60-500-a60: oklch(55.6% 0 0 / 60%); + --color-adaptive-neutral-400-a80-500-a80: oklch(55.6% 0 0 / 80%); + --color-adaptive-neutral-500-a10-a16: oklch(55.6% 0 0 / 16%); + --color-adaptive-neutral-500-400: oklch(70.8% 0 0); + --color-adaptive-neutral-500-500: oklch(55.6% 0 0); + --color-adaptive-neutral-600-300: oklch(87% 0 0); + --color-adaptive-neutral-600-400: oklch(70.8% 0 0); + --color-adaptive-neutral-950-50: oklch(98.5% 0 0); + --color-adaptive-red-50-950-a80: oklch(25.8% 0.092 26.042 / 80%); + --color-adaptive-red-200-800: oklch(44.4% 0.177 26.899); + --color-adaptive-red-600-a80-400-a80: oklch(70.4% 0.191 22.216 / 80%); + --color-adaptive-red-700-300: oklch(80.8% 0.114 19.571); + --color-adaptive-rose-100-500-a18: oklch(64.5% 0.246 16.439 / 18%); + --color-adaptive-rose-100-a80-500-a12: oklch(64.5% 0.246 16.439 / 12%); + --color-adaptive-rose-300-a70-400-a28: oklch(71.2% 0.194 13.428 / 28%); + --color-adaptive-rose-500-a12-a16: oklch(64.5% 0.246 16.439 / 16%); + --color-adaptive-rose-500-400: oklch(71.2% 0.194 13.428); + --color-adaptive-rose-600-400: oklch(71.2% 0.194 13.428); + --color-adaptive-rose-700-300: oklch(81% 0.117 11.638); + --color-adaptive-sky-500-a12-a16: oklch(68.5% 0.169 237.323 / 16%); + --color-adaptive-sky-600-400: oklch(74.6% 0.16 232.661); + --color-adaptive-sky-700-300: oklch(82.8% 0.111 230.318); + --color-adaptive-violet-500-a12-a16: oklch(60.6% 0.25 292.717 / 16%); + --color-adaptive-violet-600-400: oklch(70.2% 0.183 293.541); + --color-adaptive-violet-700-300: oklch(81.1% 0.111 293.571); + --color-adaptive-white-neutral-950-a70: oklch(14.5% 0 0 / 70%); + --color-adaptive-zinc-500-a12-a16: oklch(55.2% 0.016 285.938 / 16%); + --color-adaptive-zinc-500-400: oklch(70.5% 0.015 286.067); + --color-adaptive-zinc-600-300: oklch(87.1% 0.006 286.286); + } + + @variant iris-light { + --color-screen: #f8f7f9; + --color-sheet: rgba(248, 247, 249, 0.98); + --color-sheet-solid: #f8f7f9; + --color-card: #f0eff2; + --color-card-alt: #f8f7f9; + --color-card-translucent: rgba(240, 239, 242, 0.8); + --color-foreground: #241523; + --color-foreground-secondary: #766c76; + --color-foreground-muted: #726874; + --color-foreground-tertiary: #766c76; + --color-border: #d6d1de; + --color-border-subtle: rgba(214, 209, 222, 0.7); + --color-separator: rgba(214, 209, 222, 0.55); + --color-subtle: #f0edf6; + --color-subtle-strong: #edeaf4; + --color-inline-skill-background: #e5e0f0; + --color-inline-skill-border: rgba(114, 83, 185, 0.42); + --color-inline-skill-foreground: #241523; + --color-primary: #7253b9; + --color-primary-foreground: #fffaff; + --color-primary-shadow: #000000; + --color-secondary: #edeaf4; + --color-secondary-foreground: #241523; + --color-secondary-border: #d6d1de; + --color-switch-active-track: #7253b9; + --color-switch-active-thumb: #fffaff; + --color-switch-inactive-track: #edeaf4; + --color-switch-inactive-thumb: #726874; + --color-danger: #f8e6ea; + --color-danger-border: rgba(251, 44, 54, 0.32); + --color-danger-foreground: #c10007; + --color-input: #f0eff2; + --color-input-border: #ccc5d6; + --color-sidebar-search: #ddd9e3; + --color-placeholder: #736973; + --color-icon: #241523; + --color-icon-muted: #766c76; + --color-icon-subtle: #766c76; + --color-header: rgba(248, 247, 249, 0.97); + --color-header-border: #e5e0f0; + --color-glass-surface: rgba(235, 233, 237, 0.74); + --color-glass-tint: rgba(235, 233, 237, 0.22); + --color-status-bar: #f8f7f9; + --color-md-body: #241523; + --color-md-strong: #241523; + --color-md-link: #7253b9; + --color-md-blockquote-border: #d6d1de; + --color-md-blockquote-bg: #f0edf6; + --color-md-code-bg: #f2f1f4; + --color-md-code-text: #241523; + --color-md-user-code-bg: rgba(36, 21, 35, 0.18); + --color-md-user-code-text: #241523; + --color-md-user-fence-bg: rgba(0, 0, 0, 0.16); + --color-md-user-fence-text: #241523; + --color-md-hr: #d6d1de; + --color-user-bubble: #e0d9ee; + --color-user-bubble-foreground: #241523; + --color-user-bubble-foreground-muted: rgba(36, 21, 35, 0.78); + --color-user-bubble-skill-foreground: #a82c87; + --color-backdrop: rgba(0, 0, 0, 0.22); + --color-drawer: rgba(237, 234, 244, 0.99); + --color-drawer-shadow: rgba(0, 0, 0, 0.12); + --color-dot-separator: rgba(118, 108, 118, 0.35); + --color-wordmark: #241523; + --color-chevron: rgba(118, 108, 118, 0.42); + --color-adaptive-amber-50-950-a40: oklch(98.7% 0.022 95.277); + --color-adaptive-amber-200-900-a60: oklch(92.4% 0.12 95.746); + --color-adaptive-amber-500-a12-a16: oklch(76.9% 0.188 70.08 / 12%); + --color-adaptive-amber-700-300: oklch(55.5% 0.163 48.998); + --color-adaptive-amber-700-400: oklch(55.5% 0.163 48.998); + --color-adaptive-amber-800-200: oklch(47.3% 0.137 46.201); + --color-adaptive-blue-50-blue-400-a14: oklch(97% 0.014 254.604); + --color-adaptive-blue-300-a50-blue-400-a28: oklch(80.9% 0.105 251.813 / 50%); + --color-adaptive-blue-500-a20-blue-400-a15: oklch(62.3% 0.214 259.815 / 20%); + --color-adaptive-blue-500-400: oklch(62.3% 0.214 259.815); + --color-adaptive-blue-600-400: oklch(54.6% 0.245 262.881); + --color-adaptive-black-a10-a25: rgb(0 0 0 / 10%); + --color-adaptive-black-a15-a35: rgb(0 0 0 / 15%); + --color-adaptive-emerald-500-a12-a16: oklch(69.6% 0.17 162.48 / 12%); + --color-adaptive-emerald-600-400: oklch(59.6% 0.145 163.225); + --color-adaptive-emerald-700-300: oklch(50.8% 0.118 165.612); + --color-adaptive-indigo-500-a12-a16: oklch(58.5% 0.233 277.117 / 12%); + --color-adaptive-indigo-600-300: oklch(51.1% 0.262 276.966); + --color-adaptive-indigo-700-300: oklch(45.7% 0.24 277.023); + --color-adaptive-neutral-100-900: oklch(97% 0 0); + --color-adaptive-neutral-200-700-a60: oklch(92.2% 0 0); + --color-adaptive-neutral-200-800: oklch(92.2% 0 0); + --color-adaptive-neutral-200-a70-white-a8: oklch(92.2% 0 0 / 70%); + --color-adaptive-neutral-200-white-a6: oklch(92.2% 0 0); + --color-adaptive-neutral-200-white-a8: oklch(92.2% 0 0); + --color-adaptive-neutral-200-a80-white-a8: oklch(92.2% 0 0 / 80%); + --color-adaptive-neutral-300-a60-white-a12: oklch(87% 0 0 / 60%); + --color-adaptive-neutral-400-500: oklch(70.8% 0 0); + --color-adaptive-neutral-400-a60-500-a60: oklch(70.8% 0 0 / 60%); + --color-adaptive-neutral-400-a80-500-a80: oklch(70.8% 0 0 / 80%); + --color-adaptive-neutral-500-a10-a16: oklch(55.6% 0 0 / 10%); + --color-adaptive-neutral-500-400: oklch(55.6% 0 0); + --color-adaptive-neutral-500-500: oklch(55.6% 0 0); + --color-adaptive-neutral-600-300: oklch(43.9% 0 0); + --color-adaptive-neutral-600-400: oklch(43.9% 0 0); + --color-adaptive-neutral-950-50: oklch(14.5% 0 0); + --color-adaptive-red-50-950-a80: oklch(97.1% 0.013 17.38); + --color-adaptive-red-200-800: oklch(88.5% 0.062 18.334); + --color-adaptive-red-600-a80-400-a80: oklch(57.7% 0.245 27.325 / 80%); + --color-adaptive-red-700-300: oklch(50.5% 0.213 27.518); + --color-adaptive-rose-100-500-a18: oklch(94.1% 0.03 12.58); + --color-adaptive-rose-100-a80-500-a12: oklch(94.1% 0.03 12.58 / 80%); + --color-adaptive-rose-300-a70-400-a28: oklch(81% 0.117 11.638 / 70%); + --color-adaptive-rose-500-a12-a16: oklch(64.5% 0.246 16.439 / 12%); + --color-adaptive-rose-500-400: oklch(64.5% 0.246 16.439); + --color-adaptive-rose-600-400: oklch(58.6% 0.253 17.585); + --color-adaptive-rose-700-300: oklch(51.4% 0.222 16.935); + --color-adaptive-sky-500-a12-a16: oklch(68.5% 0.169 237.323 / 12%); + --color-adaptive-sky-600-400: oklch(58.8% 0.158 241.966); + --color-adaptive-sky-700-300: oklch(50% 0.134 242.749); + --color-adaptive-violet-500-a12-a16: oklch(60.6% 0.25 292.717 / 12%); + --color-adaptive-violet-600-400: oklch(54.1% 0.281 293.009); + --color-adaptive-violet-700-300: oklch(49.1% 0.27 292.581); + --color-adaptive-white-neutral-950-a70: #fff; + --color-adaptive-zinc-500-a12-a16: oklch(55.2% 0.016 285.938 / 12%); + --color-adaptive-zinc-500-400: oklch(55.2% 0.016 285.938); + --color-adaptive-zinc-600-300: oklch(44.2% 0.017 285.786); + } + + @variant iris-dark { + --color-screen: #1d1929; + --color-sheet: rgba(29, 25, 41, 0.98); + --color-sheet-solid: #1d1929; + --color-card: #383443; + --color-card-alt: #1d1929; + --color-card-translucent: rgba(56, 52, 67, 0.8); + --color-foreground: #fffaff; + --color-foreground-secondary: #8e8a95; + --color-foreground-muted: #9690a1; + --color-foreground-tertiary: #8e8a95; + --color-border: #4d4366; + --color-border-subtle: rgba(77, 67, 102, 0.7); + --color-separator: rgba(77, 67, 102, 0.55); + --color-subtle: #2d2643; + --color-subtle-strong: #362d51; + --color-inline-skill-background: #433765; + --color-inline-skill-border: rgba(157, 125, 242, 0.42); + --color-inline-skill-foreground: #fffaff; + --color-primary: #9d7df2; + --color-primary-foreground: #241523; + --color-primary-shadow: #000000; + --color-secondary: #362d51; + --color-secondary-foreground: #fffaff; + --color-secondary-border: #4d4366; + --color-switch-active-track: #9d7df2; + --color-switch-active-thumb: #241523; + --color-switch-inactive-track: #362d51; + --color-switch-inactive-thumb: #9690a1; + --color-danger: #40202e; + --color-danger-border: rgba(251, 65, 74, 0.32); + --color-danger-foreground: #ff6467; + --color-input: #383443; + --color-input-border: #5d527b; + --color-sidebar-search: #494459; + --color-placeholder: #a29ea8; + --color-icon: #fffaff; + --color-icon-muted: #8e8a95; + --color-icon-subtle: #8e8a95; + --color-header: rgba(29, 25, 41, 0.97); + --color-header-border: #4a3c70; + --color-glass-surface: rgba(69, 66, 80, 0.74); + --color-glass-tint: rgba(69, 66, 80, 0.22); + --color-status-bar: #1d1929; + --color-md-body: #fffaff; + --color-md-strong: #fffaff; + --color-md-link: #9d7df2; + --color-md-blockquote-border: #4d4366; + --color-md-blockquote-bg: #2d2643; + --color-md-code-bg: #2a2736; + --color-md-code-text: #fffaff; + --color-md-user-code-bg: rgba(255, 250, 255, 0.18); + --color-md-user-code-text: #fffaff; + --color-md-user-fence-bg: rgba(0, 0, 0, 0.28); + --color-md-user-fence-text: #fffaff; + --color-md-hr: #4d4366; + --color-user-bubble: #4b3d72; + --color-user-bubble-foreground: #fffaff; + --color-user-bubble-foreground-muted: rgba(255, 250, 255, 0.78); + --color-user-bubble-skill-foreground: #f099d8; + --color-backdrop: rgba(0, 0, 0, 0.48); + --color-drawer: rgba(39, 33, 57, 0.99); + --color-drawer-shadow: rgba(0, 0, 0, 0.32); + --color-dot-separator: rgba(142, 138, 149, 0.35); + --color-wordmark: #fffaff; + --color-chevron: rgba(142, 138, 149, 0.42); + --color-adaptive-amber-50-950-a40: oklch(27.9% 0.077 45.635 / 40%); + --color-adaptive-amber-200-900-a60: oklch(41.4% 0.112 45.904 / 60%); + --color-adaptive-amber-500-a12-a16: oklch(76.9% 0.188 70.08 / 16%); + --color-adaptive-amber-700-300: oklch(87.9% 0.169 91.605); + --color-adaptive-amber-700-400: oklch(82.8% 0.189 84.429); + --color-adaptive-amber-800-200: oklch(92.4% 0.12 95.746); + --color-adaptive-blue-50-blue-400-a14: oklch(70.7% 0.165 254.624 / 14%); + --color-adaptive-blue-300-a50-blue-400-a28: oklch(70.7% 0.165 254.624 / 28%); + --color-adaptive-blue-500-a20-blue-400-a15: oklch(70.7% 0.165 254.624 / 15%); + --color-adaptive-blue-500-400: oklch(70.7% 0.165 254.624); + --color-adaptive-blue-600-400: oklch(70.7% 0.165 254.624); + --color-adaptive-black-a10-a25: rgb(0 0 0 / 25%); + --color-adaptive-black-a15-a35: rgb(0 0 0 / 35%); + --color-adaptive-emerald-500-a12-a16: oklch(69.6% 0.17 162.48 / 16%); + --color-adaptive-emerald-600-400: oklch(76.5% 0.177 163.223); + --color-adaptive-emerald-700-300: oklch(84.5% 0.143 164.978); + --color-adaptive-indigo-500-a12-a16: oklch(58.5% 0.233 277.117 / 16%); + --color-adaptive-indigo-600-300: oklch(78.5% 0.115 274.713); + --color-adaptive-indigo-700-300: oklch(78.5% 0.115 274.713); + --color-adaptive-neutral-100-900: oklch(20.5% 0 0); + --color-adaptive-neutral-200-700-a60: oklch(37.1% 0 0 / 60%); + --color-adaptive-neutral-200-800: oklch(26.9% 0 0); + --color-adaptive-neutral-200-a70-white-a8: rgb(255 255 255 / 8%); + --color-adaptive-neutral-200-white-a6: rgb(255 255 255 / 6%); + --color-adaptive-neutral-200-white-a8: rgb(255 255 255 / 8%); + --color-adaptive-neutral-200-a80-white-a8: rgb(255 255 255 / 8%); + --color-adaptive-neutral-300-a60-white-a12: rgb(255 255 255 / 12%); + --color-adaptive-neutral-400-500: oklch(55.6% 0 0); + --color-adaptive-neutral-400-a60-500-a60: oklch(55.6% 0 0 / 60%); + --color-adaptive-neutral-400-a80-500-a80: oklch(55.6% 0 0 / 80%); + --color-adaptive-neutral-500-a10-a16: oklch(55.6% 0 0 / 16%); + --color-adaptive-neutral-500-400: oklch(70.8% 0 0); + --color-adaptive-neutral-500-500: oklch(55.6% 0 0); + --color-adaptive-neutral-600-300: oklch(87% 0 0); + --color-adaptive-neutral-600-400: oklch(70.8% 0 0); + --color-adaptive-neutral-950-50: oklch(98.5% 0 0); + --color-adaptive-red-50-950-a80: oklch(25.8% 0.092 26.042 / 80%); + --color-adaptive-red-200-800: oklch(44.4% 0.177 26.899); + --color-adaptive-red-600-a80-400-a80: oklch(70.4% 0.191 22.216 / 80%); + --color-adaptive-red-700-300: oklch(80.8% 0.114 19.571); + --color-adaptive-rose-100-500-a18: oklch(64.5% 0.246 16.439 / 18%); + --color-adaptive-rose-100-a80-500-a12: oklch(64.5% 0.246 16.439 / 12%); + --color-adaptive-rose-300-a70-400-a28: oklch(71.2% 0.194 13.428 / 28%); + --color-adaptive-rose-500-a12-a16: oklch(64.5% 0.246 16.439 / 16%); + --color-adaptive-rose-500-400: oklch(71.2% 0.194 13.428); + --color-adaptive-rose-600-400: oklch(71.2% 0.194 13.428); + --color-adaptive-rose-700-300: oklch(81% 0.117 11.638); + --color-adaptive-sky-500-a12-a16: oklch(68.5% 0.169 237.323 / 16%); + --color-adaptive-sky-600-400: oklch(74.6% 0.16 232.661); + --color-adaptive-sky-700-300: oklch(82.8% 0.111 230.318); + --color-adaptive-violet-500-a12-a16: oklch(60.6% 0.25 292.717 / 16%); + --color-adaptive-violet-600-400: oklch(70.2% 0.183 293.541); + --color-adaptive-violet-700-300: oklch(81.1% 0.111 293.571); + --color-adaptive-white-neutral-950-a70: oklch(14.5% 0 0 / 70%); + --color-adaptive-zinc-500-a12-a16: oklch(55.2% 0.016 285.938 / 16%); + --color-adaptive-zinc-500-400: oklch(70.5% 0.015 286.067); + --color-adaptive-zinc-600-300: oklch(87.1% 0.006 286.286); + } + } +} diff --git a/apps/mobile/global.css b/apps/mobile/global.css index a42afc74d92f..e6961eac4eea 100644 --- a/apps/mobile/global.css +++ b/apps/mobile/global.css @@ -1,5 +1,6 @@ @import "tailwindcss"; @import "uniwind"; +@import "./generated-uniwind-themes.css"; /* ─── Theme tokens ──────────────────────────────────────────────────── */ @layer theme { diff --git a/apps/mobile/metro.config.js b/apps/mobile/metro.config.js index fe886077697c..3791d347c62b 100644 --- a/apps/mobile/metro.config.js +++ b/apps/mobile/metro.config.js @@ -2,6 +2,7 @@ const fs = require("node:fs"); const path = require("node:path"); const { getDefaultConfig } = require("expo/metro-config"); const { withUniwindConfig } = require("uniwind/metro"); +const extraThemes = require("./generated-uniwind-theme-names.json"); /** @type {import("expo/metro-config").MetroConfig} */ const config = getDefaultConfig(__dirname); @@ -50,5 +51,6 @@ config.resolver = { module.exports = withUniwindConfig(config, { cssEntryFile: "./global.css", + extraThemes, polyfills: { rem: 14 }, }); diff --git a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorModule.swift b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorModule.swift index a56619b7d483..06dab5e074d4 100644 --- a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorModule.swift +++ b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorModule.swift @@ -29,6 +29,9 @@ public class T3ComposerEditorModule: Module { Prop("editable") { (view: T3ComposerEditorView, editable: Bool) in view.setEditable(editable) } + Prop("readOnly") { (view: T3ComposerEditorView, readOnly: Bool) in + view.setReadOnly(readOnly) + } Prop("scrollEnabled") { (view: T3ComposerEditorView, scrollEnabled: Bool) in view.setScrollEnabled(scrollEnabled) } diff --git a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift index 2a8fb8c4ea26..fe63acc8eb94 100644 --- a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift +++ b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift @@ -60,10 +60,21 @@ private final class ComposerTextAttachment: NSTextAttachment { private final class ComposerTextView: UITextView { private static let pastedImageDirectoryName = "t3-composer-paste" private static let stalePastedImageAge: TimeInterval = 60 * 60 + private static let readOnlyActions = Set([ + "cut:", + "delete:", + "paste:", + "redo:", + "toggleBoldface:", + "toggleItalics:", + "toggleUnderline:", + "undo:", + ]) var onPasteImages: (([String]) -> Void)? var onAttributedMutation: (() -> Void)? var onSubmit: (() -> Void)? + var isReadOnly = false override var keyCommands: [UIKeyCommand]? { var commands = super.keyCommands ?? [] @@ -83,6 +94,9 @@ private final class ComposerTextView: UITextView { } override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { + if isReadOnly && Self.readOnlyActions.contains(NSStringFromSelector(action)) { + return false + } if action == #selector(paste(_:)) { let pasteboard = UIPasteboard.general if pasteboard.hasImages || @@ -96,6 +110,9 @@ private final class ComposerTextView: UITextView { } override func paste(_ sender: Any?) { + guard !isReadOnly else { + return + } let pasteboard = UIPasteboard.general let imageProviders = pasteboard.itemProviders.filter { $0.canLoadObject(ofClass: UIImage.self) @@ -117,6 +134,9 @@ private final class ComposerTextView: UITextView { } override func deleteBackward() { + guard !isReadOnly else { + return + } guard selectedRange.length == 0, selectedRange.location > 0 else { super.deleteBackward() return @@ -160,9 +180,12 @@ private final class ComposerTextView: UITextView { } group.notify(queue: .main) { [weak self] in + guard let self, !self.isReadOnly else { + return + } let urls = images.compactMap { $0 }.compactMap(Self.writeTemporaryImage) if !urls.isEmpty { - self?.onPasteImages?(urls) + self.onPasteImages?(urls) } } } @@ -175,6 +198,9 @@ private final class ComposerTextView: UITextView { } override func cut(_ sender: Any?) { + guard !isReadOnly else { + return + } guard isEditable, selectedRange.length > 0 else { return super.cut(sender) } @@ -306,6 +332,7 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro private var contentInsetVertical: CGFloat = 0 private var shouldAutoFocus = false private var didAutoFocus = false + private var isReadOnly = false private var isApplyingControlledValue = false private var nativeEventCount = 0 private var lastContentSize = CGSize.zero @@ -451,6 +478,11 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro textView.isEditable = editable } + func setReadOnly(_ readOnly: Bool) { + isReadOnly = readOnly + textView.isReadOnly = readOnly + } + func setScrollEnabled(_ scrollEnabled: Bool) { textView.isScrollEnabled = scrollEnabled } @@ -504,13 +536,16 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro replacementText text: String ) -> Bool { restoreBaseTypingAttributes() - return true + return !isReadOnly } public func textDroppableView( _ textDroppableView: UIView & UITextDroppable, proposalForDrop drop: UITextDropRequest ) -> UITextDropProposal { + guard !isReadOnly else { + return UITextDropProposal(operation: .cancel) + } guard droppedImageProviders(in: drop) != nil else { return drop.suggestedProposal } @@ -527,6 +562,9 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro _ textDroppableView: UIView & UITextDroppable, willPerformDrop drop: UITextDropRequest ) { + guard !isReadOnly else { + return + } guard let imageProviders = droppedImageProviders(in: drop) else { return } diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_video.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_video.png new file mode 100644 index 000000000000..673c95d8b36a Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/pierre_video.png differ diff --git a/apps/mobile/modules/t3-markdown-text/package.json b/apps/mobile/modules/t3-markdown-text/package.json index d51b6c5d9ff7..7ab3f1fbde82 100644 --- a/apps/mobile/modules/t3-markdown-text/package.json +++ b/apps/mobile/modules/t3-markdown-text/package.json @@ -26,6 +26,8 @@ "./types": "./src/SelectableMarkdownText.types.ts" }, "peerDependencies": { + "@t3tools/client-runtime": "*", + "@t3tools/shared": "*", "expo-asset": "*", "expo-clipboard": "*", "expo-haptics": "*", diff --git a/apps/mobile/modules/t3-markdown-text/scripts/sync-pierre-file-icons.mjs b/apps/mobile/modules/t3-markdown-text/scripts/sync-pierre-file-icons.mjs index 2c2cc43bc655..8510ce97ba68 100644 --- a/apps/mobile/modules/t3-markdown-text/scripts/sync-pierre-file-icons.mjs +++ b/apps/mobile/modules/t3-markdown-text/scripts/sync-pierre-file-icons.mjs @@ -66,6 +66,7 @@ const colors = { terraform: "#693acf", text: "#84848a", typescript: "#1a85d4", + video: "#a631be", vite: "#a631be", vscode: "#1a85d4", vue: "#199f43", @@ -83,6 +84,7 @@ const customIcons = { pnpm: "t3-file-icon-pnpm", readme: "t3-file-icon-readme", tsconfig: "t3-file-icon-tsconfig", + video: "t3-file-icon-video", }; function symbolFromSprite(sprite, id) { diff --git a/apps/mobile/modules/t3-markdown-text/src/markdownFileIcons.generated.ts b/apps/mobile/modules/t3-markdown-text/src/markdownFileIcons.generated.ts index 608fa08c486e..463e00207d97 100644 --- a/apps/mobile/modules/t3-markdown-text/src/markdownFileIcons.generated.ts +++ b/apps/mobile/modules/t3-markdown-text/src/markdownFileIcons.generated.ts @@ -51,6 +51,7 @@ export const MARKDOWN_FILE_ICON_SOURCES = { text: require("../assets/file-icons/pierre_text.png"), tsconfig: require("../assets/file-icons/pierre_tsconfig.png"), typescript: require("../assets/file-icons/pierre_typescript.png"), + video: require("../assets/file-icons/pierre_video.png"), vite: require("../assets/file-icons/pierre_vite.png"), vscode: require("../assets/file-icons/pierre_vscode.png"), vue: require("../assets/file-icons/pierre_vue.png"), diff --git a/apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts b/apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts index f13891e3ff80..0caa24c3404f 100644 --- a/apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts +++ b/apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts @@ -1,10 +1,18 @@ +import { + inlineCodeFilePathCandidate, + isConventionalFilePosition, +} from "@t3tools/client-runtime/markdown-links"; +import { videoMimeType } from "@t3tools/shared/video"; + import type { MARKDOWN_FILE_ICON_SOURCES } from "./markdownFileIcons.generated"; const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:[\\/]/; const WINDOWS_UNC_PATH_PATTERN = /^\\\\/; const RELATIVE_PATH_PREFIX_PATTERN = /^(~\/|\.{1,2}\/)/; -const RELATIVE_FILE_PATH_PATTERN = /^[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)+(?::\d+){0,2}$/; -const RELATIVE_FILE_NAME_PATTERN = /^[A-Za-z0-9._-]+\.[A-Za-z0-9_-]+(?::\d+){0,2}$/; +const RELATIVE_FILE_PATH_PATTERN = + /^(?:[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*\/)+[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*(?::\d+){0,2}$/; +const RELATIVE_FILE_NAME_PATTERN = + /^[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*\.[A-Za-z0-9_-]+(?::\d+){0,2}$/; const POSITION_SUFFIX_PATTERN = /:\d+(?::\d+)?$/; const POSIX_FILE_ROOT_PREFIXES = [ "/Users/", @@ -251,15 +259,22 @@ function normalizeDestination(value: string): string { return trimmed.startsWith("<") && trimmed.endsWith(">") ? trimmed.slice(1, -1) : trimmed; } +/** Native link and media APIs have no document scheme to inherit from protocol-relative URLs. */ +export function normalizeNativeMarkdownUrl(value: string): string { + return value.startsWith("//") ? `https:${value}` : value; +} + function fileUrlTarget(href: string): { readonly path: string; readonly hash: string } | null { try { const parsed = new URL(href); if (parsed.protocol.toLowerCase() !== "file:") { return null; } - const path = /^\/[A-Za-z]:[\\/]/.test(parsed.pathname) - ? parsed.pathname.slice(1) + const uncHostname = parsed.hostname.toLowerCase() === "localhost" ? "" : parsed.hostname; + const rawPath = uncHostname + ? `\\\\${uncHostname}${parsed.pathname.replaceAll("/", "\\")}` : parsed.pathname; + const path = /^\/[A-Za-z]:[\\/]/.test(rawPath) ? rawPath.slice(1) : rawPath; return { path, hash: parsed.hash }; } catch { return null; @@ -325,6 +340,7 @@ function looksLikeFilePath(value: string): boolean { if (FILE_ICON_BY_NAME[value.replace(POSITION_SUFFIX_PATTERN, "").toLowerCase()]) { return true; } + if (isConventionalFilePosition(value)) return true; return RELATIVE_FILE_PATH_PATTERN.test(value) || RELATIVE_FILE_NAME_PATTERN.test(value); } @@ -336,6 +352,7 @@ function fileLabel(value: string): string { export function resolveMarkdownFileIcon(value: string): MarkdownFileIcon { const basename = fileLabel(value).replace(POSITION_SUFFIX_PATTERN, "").toLowerCase(); + if (videoMimeType({ name: basename, mimeType: "" }) !== null) return "video"; const exactIcon = FILE_ICON_BY_NAME[basename]; if (exactIcon) return exactIcon; if (basename.startsWith("tsconfig.") && basename.endsWith(".json")) { @@ -352,7 +369,7 @@ export function resolveMarkdownFileIcon(value: string): MarkdownFileIcon { export function resolveMarkdownLinkPresentation(href: string): MarkdownLinkPresentation { const normalized = normalizeDestination(href); try { - const parsed = new URL(normalized); + const parsed = new URL(normalizeNativeMarkdownUrl(normalized)); if (parsed.protocol === "http:" || parsed.protocol === "https:") { return { kind: "external", @@ -397,3 +414,13 @@ export function resolveMarkdownLinkPresentation(href: string): MarkdownLinkPrese href: /^(?:mailto|tel):/i.test(normalized) ? normalized : null, }; } + +/** Backticks become file references only when the shared path heuristic recognizes the whole span. */ +export function resolveMarkdownInlineCodePresentation( + content: string, +): Extract | null { + const candidate = inlineCodeFilePathCandidate(content); + if (candidate === null) return null; + const presentation = resolveMarkdownLinkPresentation(candidate); + return presentation.kind === "file" ? presentation : null; +} diff --git a/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts b/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts index 8db904b5a6ca..2b39ac201599 100644 --- a/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts +++ b/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts @@ -1,7 +1,11 @@ import type { MarkdownNode } from "react-native-nitro-markdown/headless"; import type { SelectableMarkdownSkill } from "./SelectableMarkdownText.types"; -import { resolveMarkdownLinkPresentation, type MarkdownFileIcon } from "./markdownLinks"; +import { + resolveMarkdownInlineCodePresentation, + resolveMarkdownLinkPresentation, + type MarkdownFileIcon, +} from "./markdownLinks"; export interface NativeMarkdownTextRun { readonly text: string; @@ -283,8 +287,17 @@ function appendNode( return appendRun(runs, textNodeContent(nodeTextContent(node)), context); case "html_inline": return appendRun(runs, inlineHtmlText(nodeTextContent(node)), context); - case "code_inline": - return appendRun(runs, nodeTextContent(node), { ...context, code: true }); + case "code_inline": { + const content = nodeTextContent(node); + const presentation = context.href ? null : resolveMarkdownInlineCodePresentation(content); + return presentation + ? appendRun(runs, presentation.label, { + ...context, + href: presentation.href, + fileIcon: presentation.icon, + }) + : appendRun(runs, content, { ...context, code: true }); + } case "soft_break": return appendRun(runs, " ", context); case "line_break": diff --git a/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3KeyboardCommandsModule.kt b/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3KeyboardCommandsModule.kt new file mode 100644 index 000000000000..68608d9eb3f9 --- /dev/null +++ b/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3KeyboardCommandsModule.kt @@ -0,0 +1,46 @@ +package expo.modules.t3nativecontrols + +import android.content.Context +import android.view.KeyEvent +import expo.modules.kotlin.AppContext +import expo.modules.kotlin.modules.Module +import expo.modules.kotlin.modules.ModuleDefinition +import expo.modules.kotlin.viewevent.EventDispatcher +import expo.modules.kotlin.views.ExpoView + +class T3KeyboardCommandsModule : Module() { + override fun definition() = ModuleDefinition { + Name("T3KeyboardCommands") + + View(T3KeyboardCommandsView::class) { + Prop("enabledCommands") { view: T3KeyboardCommandsView, commands: List -> + view.enabledCommands = commands.toSet() + } + Events("onCommand") + } + } +} + +class T3KeyboardCommandsView( + context: Context, + appContext: AppContext +) : ExpoView(context, appContext) { + private val onCommand by EventDispatcher() + var enabledCommands = emptySet() + + override fun dispatchKeyEvent(event: KeyEvent): Boolean { + val copiesThreadReference = + event.action == KeyEvent.ACTION_DOWN && + event.repeatCount == 0 && + event.keyCode == KeyEvent.KEYCODE_C && + event.isCtrlPressed && + event.isShiftPressed && + !event.isAltPressed && + enabledCommands.contains("copyThreadReference") + if (copiesThreadReference) { + onCommand(mapOf("command" to "copyThreadReference")) + return true + } + return super.dispatchKeyEvent(event) + } +} diff --git a/apps/mobile/modules/t3-native-controls/expo-module.config.json b/apps/mobile/modules/t3-native-controls/expo-module.config.json index d9a77f14e254..8481d61cb5b6 100644 --- a/apps/mobile/modules/t3-native-controls/expo-module.config.json +++ b/apps/mobile/modules/t3-native-controls/expo-module.config.json @@ -4,6 +4,9 @@ "modules": ["T3NativeControlsModule", "T3KeyboardCommandsModule"] }, "android": { - "modules": ["expo.modules.t3nativecontrols.T3NativeControlsModule"] + "modules": [ + "expo.modules.t3nativecontrols.T3NativeControlsModule", + "expo.modules.t3nativecontrols.T3KeyboardCommandsModule" + ] } } diff --git a/apps/mobile/modules/t3-native-controls/ios/T3KeyboardCommandsModule.swift b/apps/mobile/modules/t3-native-controls/ios/T3KeyboardCommandsModule.swift index ea572cc7a018..f902579f4287 100644 --- a/apps/mobile/modules/t3-native-controls/ios/T3KeyboardCommandsModule.swift +++ b/apps/mobile/modules/t3-native-controls/ios/T3KeyboardCommandsModule.swift @@ -29,6 +29,13 @@ public final class T3KeyboardCommandsView: ExpoView { enabledCommand("files", input: "f", modifiers: [.command, .shift], action: #selector(openFiles), title: "Open Files"), enabledCommand("terminal", input: "t", modifiers: [.command, .shift], action: #selector(openTerminal), title: "Open Terminal"), enabledCommand("review", input: "r", modifiers: [.command, .shift], action: #selector(openReview), title: "Open Review"), + enabledCommand( + "copyThreadReference", + input: "c", + modifiers: [.command, .shift], + action: #selector(copyThreadReference), + title: "Copy PR Link or Thread ID" + ), enabledCommand("toggleSidebar", input: "\\", modifiers: .command, action: #selector(handleToggleSidebar), title: "Toggle Sidebar"), ].compactMap { $0 } } @@ -106,6 +113,7 @@ public final class T3KeyboardCommandsView: ExpoView { @objc private func openFiles() { emit("files") } @objc private func openTerminal() { emit("terminal") } @objc private func openReview() { emit("review") } + @objc private func copyThreadReference() { emit("copyThreadReference") } @objc private func handleToggleSidebar() { emit("toggleSidebar") } private func emit(_ command: String) { diff --git a/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift b/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift index 6aa8fa6bb159..ddc8a80270fa 100644 --- a/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift +++ b/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift @@ -3,9 +3,57 @@ import Security import UIKit public final class T3NativeControlsModule: Module { + private let presentationSources = T3PresentationSources() + private var videoPresentation: T3NativeVideoPresentation? + private var filePresentation: T3NativeFilePresentation? + public func definition() -> ModuleDefinition { Name("T3NativeControls") + AsyncFunction("presentVideo") { (url: URL, title: String, sourceIdentifier: String, identifier: String, promise: Promise) in + try self.presentVideo( + url: url, + title: title, + sourceIdentifier: sourceIdentifier, + identifier: identifier, + promise: promise + ) + }.runOnQueue(.main) + + AsyncFunction("dismissVideo") { (identifier: String) in + self.dismissVideo(identifier: identifier) + }.runOnQueue(.main) + + AsyncFunction("presentFile") { (url: URL, title: String, sourceIdentifier: String, identifier: String, promise: Promise) in + try self.presentFile(url: url, title: title, sourceIdentifier: sourceIdentifier, + identifier: identifier, promise: promise) + }.runOnQueue(.main) + + AsyncFunction("dismissFile") { (identifier: String) in + self.dismissFile(identifier: identifier) + }.runOnQueue(.main) + + OnDestroy { + let presentation = self.videoPresentation + let file = self.filePresentation + DispatchQueue.main.async { + presentation?.dismiss() + file?.dismiss() + } + } + + View(T3PresentationSourceView.self) { + ViewName("PresentationSource") + Prop("identifier") { (view: T3PresentationSourceView, identifier: String) in + view.sources = self.presentationSources + view.identifier = identifier + } + } + + AsyncFunction("shareFileFromSource") { (url: URL, title: String, identifier: String, promise: Promise) in + try self.shareFile(url: url, title: title, sourceIdentifier: identifier, promise: promise) + }.runOnQueue(.main) + Function("getShowcasePairingUrl") { let arguments = ProcessInfo.processInfo.arguments guard @@ -101,4 +149,65 @@ public final class T3NativeControlsModule: Module { try? scene.write(toFile: readyPath, atomically: true, encoding: .utf8) } } + + private func presentVideo(url: URL, title: String, sourceIdentifier: String, identifier: String, promise: Promise) throws { + let isPlayableURL = url.isFileURL + ? FileManager.default.isReadableFile(atPath: url.path) + : (["https", "http"].contains(url.scheme?.lowercased() ?? "") && url.host != nil) + guard videoPresentation == nil, filePresentation == nil, + let presenter = appContext?.utilities?.currentViewController(), + isPlayableURL + else { + throw NSError( + domain: "T3NativeVideo", + code: 2, + userInfo: [NSLocalizedDescriptionKey: "The video preview is no longer available."] + ) + } + let presentation = T3NativeVideoPresentation(identifier: identifier, url: url, title: title) { [weak self] error in + self?.videoPresentation = nil + if let error { promise.reject(error) } else { promise.resolve(nil) } + } + videoPresentation = presentation + presentation.present(from: presenter, sources: presentationSources, sourceIdentifier: sourceIdentifier) + } + + private func dismissVideo(identifier: String) { + if videoPresentation?.identifier == identifier { videoPresentation?.dismiss() } + } + + private func presentFile(url: URL, title: String, sourceIdentifier: String, + identifier: String, promise: Promise) throws { + guard filePresentation == nil, videoPresentation == nil, + let presenter = appContext?.utilities?.currentViewController() + else { throw URLError(.cannotLoadFromNetwork) } + let file = T3NativeFilePresentation(identifier: identifier, sources: presentationSources, + sourceIdentifier: sourceIdentifier) { [weak self] error in + self?.filePresentation = nil + if let error { promise.reject(error) } else { promise.resolve(nil) } + } + filePresentation = file + file.present(url: url, title: title, from: presenter) + } + + private func dismissFile(identifier: String) { + if filePresentation?.identifier == identifier { filePresentation?.dismiss() } + } + + private func shareFile(url: URL, title: String, sourceIdentifier: String, promise: Promise) throws { + guard let presenter = appContext?.utilities?.currentViewController() else { + throw NSError( + domain: "T3NativePresentation", + code: 2, + userInfo: [NSLocalizedDescriptionKey: "The presenting screen is no longer open."] + ) + } + try presentFileShare( + url: url, + title: title, + source: presentationSources.view(for: sourceIdentifier), + presenter: presenter, + promise: promise + ) + } } diff --git a/apps/mobile/modules/t3-native-controls/ios/T3NativeFilePresentation.swift b/apps/mobile/modules/t3-native-controls/ios/T3NativeFilePresentation.swift new file mode 100644 index 000000000000..1a7009c3821d --- /dev/null +++ b/apps/mobile/modules/t3-native-controls/ios/T3NativeFilePresentation.swift @@ -0,0 +1,165 @@ +import ImageIO +import QuickLook +import UIKit +import UniformTypeIdentifiers + +private final class FilePreviewItem: NSObject, QLPreviewItem { + var previewItemURL: URL? + var previewItemTitle: String? +} + +private final class FilePreviewController: QLPreviewController { + var onAppear: (() -> Void)? + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + onAppear?() + } +} + +/// Quick Look owns image and document controls, zooming, and source-view transitions. +final class T3NativeFilePresentation: NSObject, QLPreviewControllerDataSource, + QLPreviewControllerDelegate, UIAdaptivePresentationControllerDelegate { + let identifier: String + private var controller: UIViewController? + private let completion: (Error?) -> Void + private weak var sources: T3PresentationSources? + private let sourceIdentifier: String + private let item = FilePreviewItem() + private var loading: Task? + private var dismissRequested = false + private var finished = false + + init(identifier: String, sources: T3PresentationSources, sourceIdentifier: String, completion: @escaping (Error?) -> Void) { + self.identifier = identifier + self.sources = sources + self.sourceIdentifier = sourceIdentifier + self.completion = completion + super.init() + } + + func present(url: URL, title: String, from presenter: UIViewController) { + loading = Task { @MainActor [self] in + do { + let file = try await Self.prepareFile(url: url, title: title) + guard !finished, !Task.isCancelled else { + try? FileManager.default.removeItem(at: file.deletingLastPathComponent()) + return + } + item.previewItemURL = file + item.previewItemTitle = title + let preview = FilePreviewController() + preview.delegate = self + preview.dataSource = self + preview.onAppear = { [weak self] in self?.resumePendingDismissal() } + controller = preview + presenter.present(preview, animated: !UIAccessibility.isReduceMotionEnabled) { [self] in + resumePendingDismissal() + } + preview.presentationController?.delegate = self + } catch { + finish(error: error) + } + } + } + + func dismiss() { + dismissRequested = true + loading?.cancel() + guard !finished else { return } + guard let controller else { finish(); return } + // Drain Close from viewDidAppear after opening or cancelling an interactive dismissal. + // Starting a second modal transition while UIKit is settling the first can strand it. + guard !controller.isBeingPresented, !controller.isBeingDismissed else { return } + controller.dismiss(animated: !UIAccessibility.isReduceMotionEnabled) { [self] in finish() } + } + + private func resumePendingDismissal() { + // Appearance callbacks run before UIKit has cleared the current transition. + DispatchQueue.main.async { [weak self] in + if self?.dismissRequested == true { self?.dismiss() } + } + } + + func numberOfPreviewItems(in controller: QLPreviewController) -> Int { item.previewItemURL == nil ? 0 : 1 } + + func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem { + item + } + + func previewController(_ controller: QLPreviewController, transitionViewFor item: QLPreviewItem) -> UIView? { + guard !UIAccessibility.isReduceMotionEnabled else { return nil } + return sources?.view(for: sourceIdentifier) + } + + func previewController(_ controller: QLPreviewController, frameFor item: QLPreviewItem, + inSourceView view: AutoreleasingUnsafeMutablePointer) -> CGRect { + guard !UIAccessibility.isReduceMotionEnabled, let source = sources?.view(for: sourceIdentifier) else { return .zero } + view.pointee = source + return source.bounds + } + + func previewControllerDidDismiss(_ controller: QLPreviewController) { finish() } + + func presentationControllerDidDismiss(_ presentationController: UIPresentationController) { finish() } + + private func finish(error: Error? = nil) { + guard !finished else { return } + finished = true + loading?.cancel() + loading = nil + if let file = item.previewItemURL { + try? FileManager.default.removeItem(at: file.deletingLastPathComponent()) + } + item.previewItemURL = nil + DispatchQueue.main.async { [completion] in completion(error) } + } + + /// Copy original bytes so preview and sharing do not mutate a draft or workspace file. + nonisolated private static func prepareFile(url: URL, title: String) async throws -> URL { + try Task.checkCancellation() + let directory = FileManager.default.temporaryDirectory.appendingPathComponent("t3-preview-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + do { + let download = directory.appendingPathComponent("original") + if url.isFileURL { + try FileManager.default.copyItem(at: url, to: download) + } else if url.scheme == "data" { + try Data(contentsOf: url).write(to: download, options: .atomic) + } else { + guard ["https", "http"].contains(url.scheme?.lowercased() ?? "") else { + throw URLError(.unsupportedURL) + } + let (temporaryFile, response) = try await URLSession.shared.download(from: url) + guard let response = response as? HTTPURLResponse, (200..<300).contains(response.statusCode) else { + throw URLError(.badServerResponse) + } + try FileManager.default.moveItem(at: temporaryFile, to: download) + } + try Task.checkCancellation() + let type: UTType + if let image = CGImageSourceCreateWithURL(download as CFURL, nil), + CGImageSourceGetCount(image) > 0, let imageType = CGImageSourceGetType(image), + let detectedType = UTType(imageType as String) { + type = detectedType + } else if CGPDFDocument(download as CFURL) != nil { + type = .pdf + } else { + throw URLError(.cannotDecodeContentData) + } + let filename = URL(fileURLWithPath: title).lastPathComponent as NSString + let originalExtension = filename.pathExtension + let fileExtension = UTType(filenameExtension: originalExtension) == type + ? originalExtension : type.preferredFilenameExtension ?? "png" + let stem = filename.deletingPathExtension + var name = String(stem.prefix(60)).components(separatedBy: .controlCharacters).joined(separator: "_") + while name.utf8.count > 200 { name.removeLast() } + let file = directory.appendingPathComponent("\(name.isEmpty ? "Preview" : name).\(fileExtension)") + try FileManager.default.moveItem(at: download, to: file) + return file + } catch { + try? FileManager.default.removeItem(at: directory) + throw error + } + } +} diff --git a/apps/mobile/modules/t3-native-controls/ios/T3NativePresentation.swift b/apps/mobile/modules/t3-native-controls/ios/T3NativePresentation.swift new file mode 100644 index 000000000000..f537e8704dcb --- /dev/null +++ b/apps/mobile/modules/t3-native-controls/ios/T3NativePresentation.swift @@ -0,0 +1,75 @@ +import ExpoModulesCore +import UIKit + +final class T3PresentationSources { + private class Entry { + weak var view: UIView? + init(_ view: UIView) { self.view = view } + } + + private var entries: [String: Entry] = [:] + + func register(_ view: UIView, identifier: String) { + entries[identifier] = Entry(view) + } + + func remove(_ view: UIView, identifier: String) { + if entries[identifier]?.view == nil || entries[identifier]?.view === view { + entries.removeValue(forKey: identifier) + } + } + + func view(for identifier: String) -> UIView? { + // Use the child bounds, not the wrapper's potentially stretched layout bounds. + entries[identifier]?.view?.subviews.first + } +} + +final class T3PresentationSourceView: ExpoView { + weak var sources: T3PresentationSources? + var identifier = "" { + didSet { + sources?.remove(self, identifier: oldValue) + if !identifier.isEmpty { sources?.register(self, identifier: identifier) } + } + } + + deinit { + sources?.remove(self, identifier: identifier) + } +} + +func presentFileShare( + url: URL, + title: String, + source: UIView?, + presenter: UIViewController, + promise: Promise +) throws { + guard url.isFileURL, FileManager.default.isReadableFile(atPath: url.path) else { + throw NSError( + domain: "T3NativePresentation", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "The file is no longer available."] + ) + } + + guard let origin = source ?? presenter.view else { + throw NSError( + domain: "T3NativePresentation", + code: 2, + userInfo: [NSLocalizedDescriptionKey: "The presenting screen is no longer open."] + ) + } + + let activity = UIActivityViewController(activityItems: [url], applicationActivities: nil) + activity.title = title + activity.overrideUserInterfaceStyle = source?.traitCollection.userInterfaceStyle + ?? presenter.traitCollection.userInterfaceStyle + activity.completionWithItemsHandler = { _, _, _, _ in promise.resolve(nil) } + activity.modalPresentationStyle = .popover + activity.popoverPresentationController?.sourceView = origin + activity.popoverPresentationController?.sourceRect = source?.bounds + ?? CGRect(x: origin.bounds.midX, y: origin.bounds.maxY, width: 0, height: 0) + presenter.present(activity, animated: true) +} diff --git a/apps/mobile/modules/t3-native-controls/ios/T3NativeVideoPresentation.swift b/apps/mobile/modules/t3-native-controls/ios/T3NativeVideoPresentation.swift new file mode 100644 index 000000000000..74d2f1c7551d --- /dev/null +++ b/apps/mobile/modules/t3-native-controls/ios/T3NativeVideoPresentation.swift @@ -0,0 +1,167 @@ +import AVKit +import UIKit + +final class T3NativeVideoPresentation: NSObject, AVPlayerViewControllerDelegate, + UIAdaptivePresentationControllerDelegate { + let identifier: String + private let controller = AVPlayerViewController() + private let completion: (Error?) -> Void + private var itemObservation: NSKeyValueObservation? + private var backgroundObserver: NSObjectProtocol? + private var playbackError: Error? + private var presented = false + private var dismissRequested = false + private var finished = false + private struct AudioSessionConfiguration { + let category: AVAudioSession.Category + let mode: AVAudioSession.Mode + let options: AVAudioSession.CategoryOptions + + init(_ session: AVAudioSession) { + category = session.category + mode = session.mode + options = session.categoryOptions + } + } + private var previousAudioSession: AudioSessionConfiguration? + private weak var fullScreenController: UIViewController? + private var embedded = false + + init(identifier: String, url: URL, title: String, completion: @escaping (Error?) -> Void) { + self.identifier = identifier + self.completion = completion + super.init() + + let item = AVPlayerItem(url: url) + let metadata = AVMutableMetadataItem() + metadata.identifier = .commonIdentifierTitle + metadata.value = title as NSString + item.externalMetadata = [metadata] + controller.player = AVPlayer(playerItem: item) + controller.delegate = self + controller.overrideUserInterfaceStyle = .dark + controller.allowsPictureInPicturePlayback = false + + itemObservation = item.observe(\.status, options: [.initial, .new]) { [weak self] item, _ in + guard item.status == .failed else { return } + DispatchQueue.main.async { + guard let self else { return } + self.playbackError = item.error ?? NSError( + domain: "T3NativeVideo", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "This video couldn't be played on this device."] + ) + self.dismiss() + } + } + backgroundObserver = NotificationCenter.default.addObserver( + forName: UIApplication.didEnterBackgroundNotification, object: nil, queue: .main + ) { [weak self] _ in self?.controller.player?.pause() } + } + + func present(from presenter: UIViewController, sources: T3PresentationSources, sourceIdentifier: String) { + let audioSession = AVAudioSession.sharedInstance() + previousAudioSession = AudioSessionConfiguration(audioSession) + do { + try audioSession.setCategory(.playback, mode: .moviePlayback) + } catch { + NSLog("T3 video audio session: %@", error.localizedDescription) + } + // AVKit exposes programmatic inline-to-full-screen entry through this selector. + // This is the same guarded entry point used by expo-video's enterFullscreen(). + let enterFullScreen = NSSelectorFromString("enterFullScreenAnimated:completionHandler:") + if let source = sources.view(for: sourceIdentifier), source.window != nil, + controller.responds(to: enterFullScreen) { + // AVKit owns the transition from its inline view to full screen. Using a + // separate UIKit zoom transition prevents its native Close action from exiting. + var responder: UIResponder? = source + while let current = responder, !(current is UIViewController) { responder = current.next } + let parent = responder as? UIViewController ?? presenter + embedded = true + parent.addChild(controller) + controller.view.frame = source.bounds + controller.view.autoresizingMask = [.flexibleWidth, .flexibleHeight] + source.addSubview(controller.view) + controller.didMove(toParent: parent) + controller.view.layoutIfNeeded() + controller.perform(enterFullScreen, with: true, with: nil) + controller.player?.play() + } else { + presenter.present(controller, animated: true) { [self] in + presented = true + if dismissRequested { + dismiss() + } else if UIApplication.shared.applicationState == .active { + controller.player?.play() + } + } + controller.presentationController?.delegate = self + } + } + + func dismiss() { + dismissRequested = true + guard !finished else { return } + guard presented else { + if embedded && fullScreenController == nil { finish() } + return + } + (fullScreenController ?? controller).dismiss(animated: true) { [self] in finish() } + } + + func playerViewController( + _ playerViewController: AVPlayerViewController, + willBeginFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator + ) { + fullScreenController = coordinator.viewController(forKey: .to) + coordinator.animate(alongsideTransition: nil) { [weak self] context in + guard let self else { return } + if context.isCancelled { + finish() + } else { + presented = true + if dismissRequested { dismiss() } + } + } + } + + func playerViewController( + _ playerViewController: AVPlayerViewController, + willEndFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator + ) { + coordinator.animate(alongsideTransition: nil) { [weak self] context in + if !context.isCancelled { self?.finish() } + } + } + + func presentationControllerDidDismiss(_ presentationController: UIPresentationController) { + finish() + } + + private func finish() { + guard !finished else { return } + finished = true + controller.player?.pause() + if embedded { + controller.willMove(toParent: nil) + controller.view.removeFromSuperview() + controller.removeFromParent() + } + itemObservation = nil + controller.player = nil + if let backgroundObserver { NotificationCenter.default.removeObserver(backgroundObserver) } + backgroundObserver = nil + let audioSession = AVAudioSession.sharedInstance() + if let previousAudioSession, audioSession.category == .playback, + audioSession.mode == .moviePlayback, audioSession.categoryOptions.isEmpty { + // AVPlayer owns activation. Deactivating the shared session here could + // stop another player or recorder that was active before this preview. + try? audioSession.setCategory( + previousAudioSession.category, + mode: previousAudioSession.mode, + options: previousAudioSession.options + ) + } + completion(playbackError) + } +} diff --git a/apps/mobile/package.json b/apps/mobile/package.json index de53a37c995b..293833b20e22 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -4,14 +4,15 @@ "private": true, "main": "index.ts", "scripts": { - "dev": "expo start --clear", - "dev:client": "APP_VARIANT=development expo start --dev-client --scheme t3code-dev --clear --lan", - "dev:client:preview": "eas env:exec preview 'EXPO_NO_DOTENV=1 APP_VARIANT=preview expo start --dev-client --scheme t3code-preview --clear --lan'", + "dev": "expo start", + "dev:client": "APP_VARIANT=development expo start --dev-client --scheme t3code-dev --lan", + "dev:client:reset": "APP_VARIANT=development expo start --dev-client --scheme t3code-dev --clear --lan", + "dev:client:preview": "eas env:exec preview 'EXPO_NO_DOTENV=1 APP_VARIANT=preview expo start --dev-client --scheme t3code-preview --lan'", "start": "expo start", "start:dev": "APP_VARIANT=development expo start", "start:preview": "APP_VARIANT=preview expo start", "start:prod": "APP_VARIANT=production expo start", - "showcase": "APP_VARIANT=production EXPO_PUBLIC_SHOWCASE=1 expo start --dev-client --scheme t3code --clear", + "showcase": "APP_VARIANT=production EXPO_PUBLIC_SHOWCASE=1 expo start --dev-client --scheme t3code", "screenshots": "node ../../scripts/mobile-showcase.ts", "android": "EXPO_NO_GIT_STATUS=1 expo prebuild --clean --platform android && expo run:android", "android:dev": "APP_VARIANT=development EXPO_NO_GIT_STATUS=1 expo prebuild --clean --platform android && REACT_NATIVE_PACKAGER_HOSTNAME=localhost expo run:android", @@ -39,20 +40,21 @@ "config:prod": "APP_VARIANT=production expo config", "profile:android:hermes": "mkdir -p profiles/review && react-native profile-hermes profiles/review", "sync:pierre-icons": "node modules/t3-markdown-text/scripts/sync-pierre-file-icons.mjs", + "generate": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON scripts/generate-uniwind-themes.mts", "test": "vp test run", "typecheck": "tsc --noEmit" }, "dependencies": { - "@callstack/liquid-glass": "^0.7.1", "@clerk/expo": "catalog:", "@effect/atom-react": "catalog:", "@expo-google-fonts/dm-sans": "^0.4.2", - "@expo/metro-runtime": "~56.0.15", - "@expo/ui": "~56.0.18", + "@expo/metro-runtime": "~57.0.14", + "@expo/ui": "~57.0.14", "@legendapp/list": "catalog:", "@noble/curves": "catalog:", "@noble/hashes": "catalog:", "@pierre/diffs": "catalog:", + "@react-native-ai/apple": "0.12.0", "@react-native-menu/menu": "^2.0.0", "@react-navigation/elements": "2.9.26", "@react-navigation/native": "7.3.4", @@ -71,60 +73,63 @@ "clsx": "^2.1.1", "diff": "8.0.3", "effect": "catalog:", - "expo": "~56.0.12", - "expo-asset": "~56.0.17", - "expo-auth-session": "~56.0.14", - "expo-blur": "~56.0.3", - "expo-build-properties": "~56.0.19", - "expo-camera": "~56.0.8", - "expo-clipboard": "~56.0.4", - "expo-constants": "~56.0.18", - "expo-crypto": "~56.0.4", - "expo-dev-client": "~56.0.20", - "expo-file-system": "~56.0.8", - "expo-font": "~56.0.7", - "expo-glass-effect": "~56.0.4", - "expo-haptics": "~56.0.3", - "expo-image": "~56.0.11", - "expo-image-picker": "~56.0.18", - "expo-linking": "~56.0.14", - "expo-network": "~56.0.5", - "expo-notifications": "~56.0.18", + "expo": "~57.0.18", + "expo-asset": "~57.0.15", + "expo-audio": "~57.0.4", + "expo-auth-session": "~57.0.10", + "expo-blur": "~57.0.2", + "expo-build-properties": "~57.0.15", + "expo-camera": "~57.0.4", + "expo-clipboard": "~57.0.1", + "expo-constants": "~57.0.16", + "expo-crypto": "~57.0.2", + "expo-dev-client": "~57.0.16", + "expo-device": "~57.0.1", + "expo-document-picker": "~57.0.1", + "expo-file-system": "~57.0.6", + "expo-font": "~57.0.2", + "expo-glass-effect": "~57.0.1", + "expo-haptics": "~57.0.2", + "expo-image": "~57.0.3", + "expo-image-picker": "~57.0.14", + "expo-linking": "~57.0.8", + "expo-network": "~57.0.1", + "expo-notifications": "~57.0.15", "expo-paste-input": "^0.1.15", "expo-quick-actions": "^6.0.2", - "expo-secure-store": "~56.0.4", - "expo-sharing": "~56.0.18", - "expo-splash-screen": "~56.0.10", - "expo-sqlite": "~56.0.5", - "expo-symbols": "~56.0.6", - "expo-updates": "~56.0.19", - "expo-web-browser": "~56.0.5", - "expo-widgets": "~56.0.19", - "punycode": "^2.3.1", + "expo-secure-store": "~57.0.2", + "expo-sharing": "~57.0.16", + "expo-splash-screen": "~57.0.8", + "expo-sqlite": "~57.0.2", + "expo-symbols": "~57.0.2", + "expo-updates": "~57.0.19", + "expo-video": "~57.0.3", + "expo-web-browser": "~57.0.2", + "expo-widgets": "~57.0.15", "react": "19.2.3", "react-dom": "19.2.3", - "react-native": "0.85.3", - "react-native-gesture-handler": "~2.31.1", + "react-native": "0.86.3", + "react-native-gesture-handler": "~2.32.0", "react-native-image-viewing": "^0.2.2", "react-native-keyboard-controller": "1.21.13", "react-native-nitro-markdown": "^0.5.0", "react-native-nitro-modules": "0.35.9", - "react-native-reanimated": "4.3.1", + "react-native-reanimated": "4.5.1", "react-native-safe-area-context": "~5.7.0", - "react-native-screens": "4.25.2", + "react-native-screens": "~4.26.0", "react-native-shiki-engine": "^0.3.12", "react-native-svg": "15.15.4", "react-native-webview": "^13.16.1", - "react-native-worklets": "0.8.3", + "react-native-worklets": "0.10.1", "shiki": "4.2.0", "tailwind-merge": "^3.5.0", - "uniwind": "^1.6.2" + "uniwind": "1.11.0" }, "devDependencies": { "@effect/vitest": "catalog:", "@pierre/trees": "1.0.0-beta.4", "@types/react": "~19.2.0", - "babel-preset-expo": "~56.0.0", + "babel-preset-expo": "~57.0.9", "tailwindcss": "^4.0.0", "typescript": "catalog:" }, @@ -132,10 +137,16 @@ "react-native-nitro-markdown": "file:deps/react-native-nitro-markdown-0.5.0.tgz" }, "expo": { + "install": { + "exclude": [ + "react-native-keyboard-controller" + ] + }, "autolinking": { "buildFromSource": [ "react-native-screens", - "@react-native-menu/menu" + "@react-native-menu/menu", + "expo-audio" ] } }, diff --git a/apps/mobile/scripts/generate-uniwind-themes.mts b/apps/mobile/scripts/generate-uniwind-themes.mts new file mode 100644 index 000000000000..aa3d9b0bfb03 --- /dev/null +++ b/apps/mobile/scripts/generate-uniwind-themes.mts @@ -0,0 +1,262 @@ +#!/usr/bin/env node + +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; +import tailwindColors from "tailwindcss/colors"; +import { BUILT_IN_THEME_IDS, type BuiltInThemeId } from "@t3tools/shared/themePalettes"; + +import { + getMobileThemeVariables, + MOBILE_THEME_VARIABLE_NAMES, + type MobileThemeAppearance, + type MobileThemeVariables, +} from "../src/lib/mobileTheme.ts"; + +const APPEARANCES = ["light", "dark"] as const; +const GLOBAL_CSS_PATH = NodePath.resolve(import.meta.dirname, "../global.css"); +const GENERATED_CSS_PATH = NodePath.resolve(import.meta.dirname, "../generated-uniwind-themes.css"); +const GENERATED_NAMES_PATH = NodePath.resolve( + import.meta.dirname, + "../generated-uniwind-theme-names.json", +); +const GENERATED_DEFAULT_VARIABLES_PATH = NodePath.resolve( + import.meta.dirname, + "../generated-uniwind-default-theme-variables.json", +); + +type TailwindColorFamily = keyof typeof tailwindColors; +type TailwindColorShade = 50 | 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900 | 950; + +const color = (family: TailwindColorFamily, shade?: TailwindColorShade, opacity = 1): string => { + const familyColors = tailwindColors[family]; + const value = + typeof familyColors === "string" + ? shade === undefined + ? familyColors + : undefined + : shade === undefined + ? undefined + : familyColors[String(shade) as keyof typeof familyColors]; + if (value === undefined) { + throw new Error(`Unknown Tailwind color ${family}${shade === undefined ? "" : `-${shade}`}.`); + } + if (opacity === 1) return value; + + const percentage = Number((opacity * 100).toFixed(4)); + const oklch = /^oklch\((.*)\)$/.exec(value); + if (oklch) return `oklch(${oklch[1]} / ${percentage}%)`; + if (value === "#fff") return `rgb(255 255 255 / ${percentage}%)`; + if (value === "#000") return `rgb(0 0 0 / ${percentage}%)`; + return `color-mix(in srgb, ${value} ${percentage}%, transparent)`; +}; + +// These replace the remaining dark:* utility pairs. A registered palette theme is +// neither literally `light` nor `dark`, so appearance-sensitive values must also be +// represented as semantic variables for custom themes. +const ADAPTIVE_COLORS = { + "--color-adaptive-amber-50-950-a40": [color("amber", 50), color("amber", 950, 0.4)], + "--color-adaptive-amber-200-900-a60": [color("amber", 200), color("amber", 900, 0.6)], + "--color-adaptive-amber-500-a12-a16": [color("amber", 500, 0.12), color("amber", 500, 0.16)], + "--color-adaptive-amber-700-300": [color("amber", 700), color("amber", 300)], + "--color-adaptive-amber-700-400": [color("amber", 700), color("amber", 400)], + "--color-adaptive-amber-800-200": [color("amber", 800), color("amber", 200)], + "--color-adaptive-blue-50-blue-400-a14": [color("blue", 50), color("blue", 400, 0.14)], + "--color-adaptive-blue-300-a50-blue-400-a28": [color("blue", 300, 0.5), color("blue", 400, 0.28)], + "--color-adaptive-blue-500-a20-blue-400-a15": [color("blue", 500, 0.2), color("blue", 400, 0.15)], + "--color-adaptive-blue-500-400": [color("blue", 500), color("blue", 400)], + "--color-adaptive-blue-600-400": [color("blue", 600), color("blue", 400)], + "--color-adaptive-black-a10-a25": [ + color("black", undefined, 0.1), + color("black", undefined, 0.25), + ], + "--color-adaptive-black-a15-a35": [ + color("black", undefined, 0.15), + color("black", undefined, 0.35), + ], + "--color-adaptive-emerald-500-a12-a16": [ + color("emerald", 500, 0.12), + color("emerald", 500, 0.16), + ], + "--color-adaptive-emerald-600-400": [color("emerald", 600), color("emerald", 400)], + "--color-adaptive-emerald-700-300": [color("emerald", 700), color("emerald", 300)], + "--color-adaptive-indigo-500-a12-a16": [color("indigo", 500, 0.12), color("indigo", 500, 0.16)], + "--color-adaptive-indigo-600-300": [color("indigo", 600), color("indigo", 300)], + "--color-adaptive-indigo-700-300": [color("indigo", 700), color("indigo", 300)], + "--color-adaptive-neutral-100-900": [color("neutral", 100), color("neutral", 900)], + "--color-adaptive-neutral-200-700-a60": [color("neutral", 200), color("neutral", 700, 0.6)], + "--color-adaptive-neutral-200-800": [color("neutral", 200), color("neutral", 800)], + "--color-adaptive-neutral-200-a70-white-a8": [ + color("neutral", 200, 0.7), + color("white", undefined, 0.08), + ], + "--color-adaptive-neutral-200-white-a6": [color("neutral", 200), color("white", undefined, 0.06)], + "--color-adaptive-neutral-200-white-a8": [color("neutral", 200), color("white", undefined, 0.08)], + "--color-adaptive-neutral-200-a80-white-a8": [ + color("neutral", 200, 0.8), + color("white", undefined, 0.08), + ], + "--color-adaptive-neutral-300-a60-white-a12": [ + color("neutral", 300, 0.6), + color("white", undefined, 0.12), + ], + "--color-adaptive-neutral-400-500": [color("neutral", 400), color("neutral", 500)], + "--color-adaptive-neutral-400-a60-500-a60": [ + color("neutral", 400, 0.6), + color("neutral", 500, 0.6), + ], + "--color-adaptive-neutral-400-a80-500-a80": [ + color("neutral", 400, 0.8), + color("neutral", 500, 0.8), + ], + "--color-adaptive-neutral-500-a10-a16": [color("neutral", 500, 0.1), color("neutral", 500, 0.16)], + "--color-adaptive-neutral-500-400": [color("neutral", 500), color("neutral", 400)], + "--color-adaptive-neutral-500-500": [color("neutral", 500), color("neutral", 500)], + "--color-adaptive-neutral-600-300": [color("neutral", 600), color("neutral", 300)], + "--color-adaptive-neutral-600-400": [color("neutral", 600), color("neutral", 400)], + "--color-adaptive-neutral-950-50": [color("neutral", 950), color("neutral", 50)], + "--color-adaptive-red-50-950-a80": [color("red", 50), color("red", 950, 0.8)], + "--color-adaptive-red-200-800": [color("red", 200), color("red", 800)], + "--color-adaptive-red-600-a80-400-a80": [color("red", 600, 0.8), color("red", 400, 0.8)], + "--color-adaptive-red-700-300": [color("red", 700), color("red", 300)], + "--color-adaptive-rose-100-500-a18": [color("rose", 100), color("rose", 500, 0.18)], + "--color-adaptive-rose-100-a80-500-a12": [color("rose", 100, 0.8), color("rose", 500, 0.12)], + "--color-adaptive-rose-300-a70-400-a28": [color("rose", 300, 0.7), color("rose", 400, 0.28)], + "--color-adaptive-rose-500-a12-a16": [color("rose", 500, 0.12), color("rose", 500, 0.16)], + "--color-adaptive-rose-500-400": [color("rose", 500), color("rose", 400)], + "--color-adaptive-rose-600-400": [color("rose", 600), color("rose", 400)], + "--color-adaptive-rose-700-300": [color("rose", 700), color("rose", 300)], + "--color-adaptive-sky-500-a12-a16": [color("sky", 500, 0.12), color("sky", 500, 0.16)], + "--color-adaptive-sky-600-400": [color("sky", 600), color("sky", 400)], + "--color-adaptive-sky-700-300": [color("sky", 700), color("sky", 300)], + "--color-adaptive-violet-500-a12-a16": [color("violet", 500, 0.12), color("violet", 500, 0.16)], + "--color-adaptive-violet-600-400": [color("violet", 600), color("violet", 400)], + "--color-adaptive-violet-700-300": [color("violet", 700), color("violet", 300)], + "--color-adaptive-white-neutral-950-a70": [color("white"), color("neutral", 950, 0.7)], + "--color-adaptive-zinc-500-a12-a16": [color("zinc", 500, 0.12), color("zinc", 500, 0.16)], + "--color-adaptive-zinc-500-400": [color("zinc", 500), color("zinc", 400)], + "--color-adaptive-zinc-600-300": [color("zinc", 600), color("zinc", 300)], +}; + +export const customThemeNames = BUILT_IN_THEME_IDS.flatMap((themeId) => + APPEARANCES.map((appearance) => `${themeId}-${appearance}`), +); + +const adaptiveVariablesFor = (appearance: MobileThemeAppearance) => + Object.fromEntries( + Object.entries(ADAPTIVE_COLORS).map(([name, values]) => [ + name, + values[appearance === "light" ? 0 : 1], + ]), + ); + +const variablesFor = (themeId: BuiltInThemeId, appearance: MobileThemeAppearance) => ({ + ...getMobileThemeVariables(themeId, appearance), + ...adaptiveVariablesFor(appearance), +}); + +const renderVariant = (name: string, variables: Readonly>) => { + const declarations = Object.entries(variables) + .map(([variable, value]) => ` ${variable}: ${value};`) + .join("\n"); + return ` @variant ${name} {\n${declarations}\n }`; +}; + +export const renderUniwindThemesCSS = () => { + const variants = [ + renderVariant("light", adaptiveVariablesFor("light")), + renderVariant("dark", adaptiveVariablesFor("dark")), + ...BUILT_IN_THEME_IDS.flatMap((themeId) => + APPEARANCES.map((appearance) => + renderVariant(`${themeId}-${appearance}`, variablesFor(themeId, appearance)), + ), + ), + ]; + return [ + "/* Generated by scripts/generate-uniwind-themes.mts. Do not edit manually. */", + "@layer theme {", + " :root {", + variants.join("\n\n"), + " }", + "}", + "", + ].join("\n"); +}; + +const readVariantBody = (css: string, appearance: MobileThemeAppearance): string => { + const marker = `@variant ${appearance} {`; + const markerIndex = css.indexOf(marker); + if (markerIndex === -1) throw new Error(`Could not find ${marker} in global.css.`); + + const openingBraceIndex = css.indexOf("{", markerIndex); + let depth = 0; + for (let index = openingBraceIndex; index < css.length; index += 1) { + if (css[index] === "{") depth += 1; + if (css[index] !== "}") continue; + depth -= 1; + if (depth === 0) return css.slice(openingBraceIndex + 1, index); + } + throw new Error(`Could not find the end of ${marker} in global.css.`); +}; + +export const readDefaultThemeVariables = (css: string) => + Object.fromEntries( + APPEARANCES.map((appearance) => { + const body = readVariantBody(css, appearance); + const variables = Object.fromEntries( + MOBILE_THEME_VARIABLE_NAMES.map((name) => { + const match = new RegExp(`^\\s*${name}:\\s*([^;]+);`, "mu").exec(body); + if (!match?.[1]) { + throw new Error(`Default ${appearance} theme is missing ${name}.`); + } + return [name, match[1].trim()]; + }), + ) as MobileThemeVariables; + return [appearance, variables]; + }), + ) as Readonly>; + +export const renderDefaultThemeVariablesJSON = (css: string) => + `${JSON.stringify(readDefaultThemeVariables(css), null, 2)}\n`; + +export const getGeneratedUniwindThemeOutputs = (): ReadonlyArray< + readonly [filename: string, contents: string] +> => [ + [GENERATED_CSS_PATH, renderUniwindThemesCSS()], + [GENERATED_NAMES_PATH, `${JSON.stringify(customThemeNames, null, 2)}\n`], + [ + GENERATED_DEFAULT_VARIABLES_PATH, + renderDefaultThemeVariablesJSON(NodeFS.readFileSync(GLOBAL_CSS_PATH, "utf8")), + ], +]; + +const writeFileAtomically = (filename: string, contents: string) => { + const current = NodeFS.existsSync(filename) ? NodeFS.readFileSync(filename, "utf8") : null; + if (current === contents) return; + + const temporaryFilename = `${filename}.${process.pid}.tmp`; + try { + NodeFS.writeFileSync(temporaryFilename, contents); + NodeFS.renameSync(temporaryFilename, filename); + } finally { + if (NodeFS.existsSync(temporaryFilename)) NodeFS.unlinkSync(temporaryFilename); + } +}; + +if (import.meta.main) { + const checkOnly = process.argv.includes("--check"); + for (const [filename, contents] of getGeneratedUniwindThemeOutputs()) { + if (checkOnly) { + const current = NodeFS.existsSync(filename) ? NodeFS.readFileSync(filename, "utf8") : null; + if (current !== contents) { + console.error( + `${NodePath.relative(process.cwd(), filename)} is stale. Run vp run --filter @t3tools/mobile generate.`, + ); + process.exitCode = 1; + } + continue; + } + // Metro watches the generated CSS. Replacing a complete temporary file keeps + // Tailwind from compiling a partially rewritten theme file. + writeFileAtomically(filename, contents); + } +} diff --git a/apps/mobile/scripts/generate-uniwind-themes.test.ts b/apps/mobile/scripts/generate-uniwind-themes.test.ts new file mode 100644 index 000000000000..48126055bada --- /dev/null +++ b/apps/mobile/scripts/generate-uniwind-themes.test.ts @@ -0,0 +1,55 @@ +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; +import { describe, expect, it } from "vite-plus/test"; + +import { + customThemeNames, + getGeneratedUniwindThemeOutputs, + readDefaultThemeVariables, + renderUniwindThemesCSS, +} from "./generate-uniwind-themes.mts"; + +describe("generate mobile Uniwind themes", () => { + it("keeps the committed outputs current", () => { + const staleOutputs = getGeneratedUniwindThemeOutputs() + .filter( + ([filename, contents]) => + !NodeFS.existsSync(filename) || NodeFS.readFileSync(filename, "utf8") !== contents, + ) + .map(([filename]) => NodePath.relative(import.meta.dirname, filename)); + + expect( + staleOutputs, + "Run `vp run --filter @t3tools/mobile generate` and commit the generated outputs.", + ).toEqual([]); + }); + + it("registers every custom palette for both appearances", () => { + expect(customThemeNames).toEqual([ + "t3-chat-light", + "t3-chat-dark", + "grove-light", + "grove-dark", + "ocean-light", + "ocean-dark", + "ember-light", + "ember-dark", + "iris-light", + "iris-dark", + ]); + + const stylesheet = renderUniwindThemesCSS(); + for (const themeName of customThemeNames) { + expect(stylesheet.match(new RegExp(`@variant ${themeName} \\{`, "gu"))).toHaveLength(1); + } + }); + + it("generates the default runtime bridge from the authored CSS", () => { + const css = NodeFS.readFileSync(NodePath.resolve(import.meta.dirname, "../global.css"), "utf8"); + const variables = readDefaultThemeVariables(css); + + expect(variables.light["--color-screen"]).toBe("#f2f2f7"); + expect(variables.dark["--color-screen"]).toBe("#0a0a0a"); + expect(Object.keys(variables.light)).toEqual(Object.keys(variables.dark)); + }); +}); diff --git a/apps/mobile/src/App.tsx b/apps/mobile/src/App.tsx index 8b219afcc078..c268056f0322 100644 --- a/apps/mobile/src/App.tsx +++ b/apps/mobile/src/App.tsx @@ -21,7 +21,6 @@ import { RootStack } from "./Stack"; import { appAtomRegistry } from "./state/atom-registry"; import { OverlayPortalHost } from "./components/OverlayPortal"; import { appBlurTargetRef } from "./lib/appBlurTarget"; -import { useThemeColor } from "./lib/useThemeColor"; import { useMobileNavigationTheme } from "./lib/useMobileNavigationTheme"; import "../global.css"; @@ -72,8 +71,7 @@ export default function App() { function AppContent() { const { themeAppearance } = useAppearancePreferences(); - const statusBarBg = useThemeColor("--color-status-bar"); - const navigationTheme = useMobileNavigationTheme(themeAppearance); + const navigationTheme = useMobileNavigationTheme(); return ( <> @@ -83,7 +81,6 @@ function AppContent() { {/* The navigation theme drives the NATIVE header appearance: native-stack diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index 7cffbf62b0d7..57303a1bb001 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -73,6 +73,7 @@ import { NATIVE_LIQUID_GLASS_SUPPORTED } from "./native/native-glass"; import { nativeHeaderScrollEdgeEffects } from "./native/StackHeader"; import { FORM_SHEET_PRESENTATION_OPTIONS } from "./native/sheet-surface"; import { useThreadOutboxDrain } from "./state/use-thread-outbox-drain"; +import { useComposerAttachmentUploadWorker } from "./state/composer-attachment-uploads"; const HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version); @@ -355,6 +356,7 @@ function workspacePathFromState(state: NavigationState): string { // each enqueue, shell change, or reconnect. function ThreadOutboxDrainWorker() { useThreadOutboxDrain(); + useComposerAttachmentUploadWorker(); return null; } diff --git a/apps/mobile/src/components/AndroidAnchoredMenu.tsx b/apps/mobile/src/components/AndroidAnchoredMenu.tsx index 7a27e0c3b131..b4e545fade71 100644 --- a/apps/mobile/src/components/AndroidAnchoredMenu.tsx +++ b/apps/mobile/src/components/AndroidAnchoredMenu.tsx @@ -9,7 +9,6 @@ import Animated, { FadeIn } from "react-native-reanimated"; import { appBlurTargetRef } from "../lib/appBlurTarget"; import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; -import { useThemeColor } from "../lib/useThemeColor"; import { cn } from "../lib/cn"; import { type AppSymbolName, SymbolView } from "./AppSymbol"; import { AppText as Text } from "./AppText"; @@ -84,11 +83,6 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) { const isDarkMode = themeAppearance === "dark"; const keyboardVisible = useKeyboardState((state) => state.isVisible); const keyboardHeight = useKeyboardState((state) => state.height); - const rippleColor = useThemeColor("--color-subtle"); - const iconColor = useThemeColor("--color-icon"); - const iconSubtleColor = useThemeColor("--color-icon-subtle"); - const dangerColor = useThemeColor("--color-danger-foreground"); - const close = useCallback(() => { setAnchor(null); setPath([]); @@ -279,10 +273,9 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) { return ( onPressItem(action)} @@ -307,21 +300,23 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) { ) : action.state === "on" ? ( ) : action.image ? ( ) : null} diff --git a/apps/mobile/src/components/AndroidScreenHeader.tsx b/apps/mobile/src/components/AndroidScreenHeader.tsx index 7fe21fb44ff3..46bc2c7c0912 100644 --- a/apps/mobile/src/components/AndroidScreenHeader.tsx +++ b/apps/mobile/src/components/AndroidScreenHeader.tsx @@ -5,7 +5,6 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { SymbolView, type AppSymbolName } from "./AppSymbol"; import { AppText as Text } from "./AppText"; import { cn } from "../lib/cn"; -import { useThemeColor } from "../lib/useThemeColor"; export interface AndroidHeaderAction { readonly accessibilityLabel: string; @@ -20,9 +19,6 @@ export function AndroidHeaderIconButton(props: { readonly onPress?: () => void; readonly disabled?: boolean; }) { - const foregroundColor = useThemeColor("--color-foreground"); - const disabledColor = useThemeColor("--color-icon-subtle"); - return ( @@ -54,7 +50,6 @@ export function AndroidScreenHeader(props: { readonly embedded?: boolean; }) { const insets = useSafeAreaInsets(); - const foregroundColor = useThemeColor("--color-foreground"); return ( diff --git a/apps/mobile/src/components/AppSymbol.ios.tsx b/apps/mobile/src/components/AppSymbol.ios.tsx new file mode 100644 index 000000000000..f1a28ed3f338 --- /dev/null +++ b/apps/mobile/src/components/AppSymbol.ios.tsx @@ -0,0 +1,15 @@ +import { SymbolView as ExpoSymbolView, type SymbolViewProps } from "expo-symbols"; +import { withUniwind } from "uniwind"; + +export type { SFSymbol } from "expo-symbols"; +export type AppSymbolName = SymbolViewProps["name"]; + +/** + * Keep the iOS implementation isolated from the Android Tabler fallback so + * Metro does not initialize the icon package when iOS renders SF Symbols. + */ +function AppSymbolView(props: SymbolViewProps) { + return ; +} + +export const SymbolView = withUniwind(AppSymbolView); diff --git a/apps/mobile/src/components/AppSymbol.tabler.d.ts b/apps/mobile/src/components/AppSymbol.tabler.d.ts new file mode 100644 index 000000000000..ae08857e021e --- /dev/null +++ b/apps/mobile/src/components/AppSymbol.tabler.d.ts @@ -0,0 +1,7 @@ +// Tabler 3.44 exports per-icon runtime modules but points their declarations at missing files. +declare module "@tabler/icons-react-native/Icon*" { + import type { Icon } from "@tabler/icons-react-native"; + + const icon: Icon; + export default icon; +} diff --git a/apps/mobile/src/components/AppSymbol.tsx b/apps/mobile/src/components/AppSymbol.tsx index 32f915e7af5c..13d9e6208570 100644 --- a/apps/mobile/src/components/AppSymbol.tsx +++ b/apps/mobile/src/components/AppSymbol.tsx @@ -1,86 +1,89 @@ -import { - IconAdjustmentsHorizontal, - IconAlertCircle, - IconAlertTriangle, - IconApps, - IconArchive, - IconArrowBackUp, - IconArrowDownCircle, - IconArrowRightCircle, - IconArrowUp, - IconArrowUpCircle, - IconArrowUpRight, - IconArrowUpRightCircle, - IconArrowsMaximize, - IconBellRinging, - IconBolt, - IconBox, - IconCamera, - IconChartBar, - IconCheck, - IconChevronDown, - IconCode, - IconChevronLeft, - IconChevronRight, - IconChevronUp, - IconCircleCheck, - IconCircleXFilled, - IconClock, - IconCopy, - IconDeviceDesktop, - IconDots, - IconDotsCircleHorizontal, - IconEdit, - IconExternalLink, - IconEye, - IconFileText, - IconFilter, - IconFolder, - IconFolderOpen, - IconFolderPlus, - IconGitBranch, - IconHammer, - IconGitMerge, - IconGitPullRequest, - IconInfoCircle, - IconKeyboard, - IconKeyboardHide, - IconLayoutColumns, - IconLayoutSidebar, - IconLetterSpacing, - IconLink, - IconMessage, - IconMinus, - IconMoon, - IconNetwork, - IconPalette, - IconPin, - IconPinnedOff, - IconPlayerPlay, - IconPlayerStopFilled, - IconPlus, - IconQrcode, - IconRefresh, - IconSearch, - IconServer, - IconSettings, - IconSparkles, - IconSun, - IconLayoutSidebarRight, - IconTerminal2, - IconTextDecrease, - IconTextIncrease, - IconTool, - IconTrash, - IconTypography, - IconUserCircle, - IconWifiOff, - IconWorld, - IconX, - type Icon, -} from "@tabler/icons-react-native"; -import { Platform } from "react-native"; -import { SymbolView as ExpoSymbolView, type SFSymbol, type SymbolViewProps } from "expo-symbols"; +import type { Icon } from "@tabler/icons-react-native/types"; +/* + * Keep these as per-icon exports. Importing the package root eagerly registers + * the entire Tabler icon set in Metro. + */ +import IconAdjustmentsHorizontal from "@tabler/icons-react-native/IconAdjustmentsHorizontal"; +import IconAlertCircle from "@tabler/icons-react-native/IconAlertCircle"; +import IconAlertTriangle from "@tabler/icons-react-native/IconAlertTriangle"; +import IconApps from "@tabler/icons-react-native/IconApps"; +import IconArchive from "@tabler/icons-react-native/IconArchive"; +import IconArrowBackUp from "@tabler/icons-react-native/IconArrowBackUp"; +import IconArrowDownCircle from "@tabler/icons-react-native/IconArrowDownCircle"; +import IconArrowRightCircle from "@tabler/icons-react-native/IconArrowRightCircle"; +import IconArrowUp from "@tabler/icons-react-native/IconArrowUp"; +import IconArrowUpCircle from "@tabler/icons-react-native/IconArrowUpCircle"; +import IconArrowUpRight from "@tabler/icons-react-native/IconArrowUpRight"; +import IconArrowUpRightCircle from "@tabler/icons-react-native/IconArrowUpRightCircle"; +import IconArrowsMaximize from "@tabler/icons-react-native/IconArrowsMaximize"; +import IconBellRinging from "@tabler/icons-react-native/IconBellRinging"; +import IconBolt from "@tabler/icons-react-native/IconBolt"; +import IconBox from "@tabler/icons-react-native/IconBox"; +import IconCamera from "@tabler/icons-react-native/IconCamera"; +import IconChartBar from "@tabler/icons-react-native/IconChartBar"; +import IconCheck from "@tabler/icons-react-native/IconCheck"; +import IconChevronDown from "@tabler/icons-react-native/IconChevronDown"; +import IconChevronLeft from "@tabler/icons-react-native/IconChevronLeft"; +import IconChevronRight from "@tabler/icons-react-native/IconChevronRight"; +import IconChevronUp from "@tabler/icons-react-native/IconChevronUp"; +import IconCircleCheck from "@tabler/icons-react-native/IconCircleCheck"; +import IconCircleXFilled from "@tabler/icons-react-native/IconCircleXFilled"; +import IconClock from "@tabler/icons-react-native/IconClock"; +import IconCode from "@tabler/icons-react-native/IconCode"; +import IconCopy from "@tabler/icons-react-native/IconCopy"; +import IconDeviceDesktop from "@tabler/icons-react-native/IconDeviceDesktop"; +import IconDots from "@tabler/icons-react-native/IconDots"; +import IconDotsCircleHorizontal from "@tabler/icons-react-native/IconDotsCircleHorizontal"; +import IconEdit from "@tabler/icons-react-native/IconEdit"; +import IconExternalLink from "@tabler/icons-react-native/IconExternalLink"; +import IconEye from "@tabler/icons-react-native/IconEye"; +import IconFileText from "@tabler/icons-react-native/IconFileText"; +import IconFilter from "@tabler/icons-react-native/IconFilter"; +import IconFolder from "@tabler/icons-react-native/IconFolder"; +import IconFolderOpen from "@tabler/icons-react-native/IconFolderOpen"; +import IconFolderPlus from "@tabler/icons-react-native/IconFolderPlus"; +import IconGitBranch from "@tabler/icons-react-native/IconGitBranch"; +import IconGitMerge from "@tabler/icons-react-native/IconGitMerge"; +import IconGitPullRequest from "@tabler/icons-react-native/IconGitPullRequest"; +import IconHammer from "@tabler/icons-react-native/IconHammer"; +import IconInfoCircle from "@tabler/icons-react-native/IconInfoCircle"; +import IconKeyboard from "@tabler/icons-react-native/IconKeyboard"; +import IconKeyboardHide from "@tabler/icons-react-native/IconKeyboardHide"; +import IconLayoutColumns from "@tabler/icons-react-native/IconLayoutColumns"; +import IconLayoutSidebar from "@tabler/icons-react-native/IconLayoutSidebar"; +import IconLayoutSidebarRight from "@tabler/icons-react-native/IconLayoutSidebarRight"; +import IconLetterSpacing from "@tabler/icons-react-native/IconLetterSpacing"; +import IconLink from "@tabler/icons-react-native/IconLink"; +import IconMessage from "@tabler/icons-react-native/IconMessage"; +import IconMinus from "@tabler/icons-react-native/IconMinus"; +import IconMoon from "@tabler/icons-react-native/IconMoon"; +import IconNetwork from "@tabler/icons-react-native/IconNetwork"; +import IconPalette from "@tabler/icons-react-native/IconPalette"; +import IconPhoto from "@tabler/icons-react-native/IconPhoto"; +import IconPin from "@tabler/icons-react-native/IconPin"; +import IconPinnedOff from "@tabler/icons-react-native/IconPinnedOff"; +import IconPlayerPlay from "@tabler/icons-react-native/IconPlayerPlay"; +import IconPlayerStopFilled from "@tabler/icons-react-native/IconPlayerStopFilled"; +import IconPlus from "@tabler/icons-react-native/IconPlus"; +import IconQrcode from "@tabler/icons-react-native/IconQrcode"; +import IconRefresh from "@tabler/icons-react-native/IconRefresh"; +import IconSearch from "@tabler/icons-react-native/IconSearch"; +import IconServer from "@tabler/icons-react-native/IconServer"; +import IconSettings from "@tabler/icons-react-native/IconSettings"; +import IconSparkles from "@tabler/icons-react-native/IconSparkles"; +import IconSun from "@tabler/icons-react-native/IconSun"; +import IconTerminal2 from "@tabler/icons-react-native/IconTerminal2"; +import IconTextDecrease from "@tabler/icons-react-native/IconTextDecrease"; +import IconTextIncrease from "@tabler/icons-react-native/IconTextIncrease"; +import IconTool from "@tabler/icons-react-native/IconTool"; +import IconTrash from "@tabler/icons-react-native/IconTrash"; +import IconTypography from "@tabler/icons-react-native/IconTypography"; +import IconUserCircle from "@tabler/icons-react-native/IconUserCircle"; +import IconWifiOff from "@tabler/icons-react-native/IconWifiOff"; +import IconWorld from "@tabler/icons-react-native/IconWorld"; +import IconX from "@tabler/icons-react-native/IconX"; +import type { SFSymbol, SymbolViewProps } from "expo-symbols"; +import { withUniwind } from "uniwind"; const ANDROID_ICON_BY_SF_SYMBOL: Partial> = { "arrow.branch": IconGitBranch, @@ -131,6 +134,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial> = { magnifyingglass: IconSearch, paintbrush: IconPalette, "person.crop.circle": IconUserCircle, + photo: IconPhoto, pin: IconPin, "pin.slash": IconPinnedOff, play: IconPlayerPlay, @@ -190,11 +194,7 @@ const ANDROID_ICON_BY_MATERIAL_NAME: Record = { export type { SFSymbol } from "expo-symbols"; export type AppSymbolName = SymbolViewProps["name"]; -export function SymbolView(props: SymbolViewProps) { - if (Platform.OS !== "android") { - return ; - } - +function AppSymbolView(props: SymbolViewProps) { const materialName = typeof props.name === "string" ? undefined : props.name.android; const sfSymbol = typeof props.name === "string" ? props.name : props.name.ios; const AndroidIcon = @@ -216,3 +216,11 @@ export function SymbolView(props: SymbolViewProps) { /> ); } + +/** + * expo-symbols and the Android Tabler fallback both expose tint as a native + * prop rather than a React Native style. Keep that third-party boundary here + * so callers can use Uniwind's `tintColorClassName` instead of subscribing to + * theme variables in every parent component. + */ +export const SymbolView = withUniwind(AppSymbolView); diff --git a/apps/mobile/src/components/CompactBrandTitle.tsx b/apps/mobile/src/components/CompactBrandTitle.tsx index bfba418c9fce..a6e2a7fd2121 100644 --- a/apps/mobile/src/components/CompactBrandTitle.tsx +++ b/apps/mobile/src/components/CompactBrandTitle.tsx @@ -1,30 +1,18 @@ import Constants from "expo-constants"; -import type { - NativeStackHeaderItem, - NativeStackNavigationOptions, -} from "@react-navigation/native-stack"; +import type { NativeStackNavigationOptions } from "@react-navigation/native-stack"; import { Platform, View } from "react-native"; import { AppText as Text } from "./AppText"; import { T3Wordmark } from "./T3Wordmark"; import { IPAD_HOME_TITLE_OFFSET } from "../lib/layoutMetrics"; import { resolveMobileStageLabel } from "../lib/mobileBranding"; -import { useThemeColor } from "../lib/useThemeColor"; -import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../native/native-glass"; - -// Native leading items inherit different UIKit margins than title views. -const IOS_NATIVE_LEADING_TITLE_OFFSET = -6; -const IPAD_NATIVE_LEADING_TITLE_OFFSET = 7; /** * Horizontal correction applied to content rendered in the brand title slot, * shared with the connection-status swap so both align identically. */ -export function brandTitleOffset(nativeLeadingItem: boolean): number { +export function brandTitleOffset(): number { if (Platform.OS !== "ios") return 0; - if (nativeLeadingItem) { - return Platform.isPad ? IPAD_NATIVE_LEADING_TITLE_OFFSET : IOS_NATIVE_LEADING_TITLE_OFFSET; - } return Platform.isPad ? IPAD_HOME_TITLE_OFFSET : 0; } @@ -34,14 +22,10 @@ export function brandTitleOffset(nativeLeadingItem: boolean): number { export function CompactBrandTitle( props: { readonly allowFontScaling?: boolean; - readonly nativeLeadingItem?: boolean; } = {}, ) { - const iconColor = useThemeColor("--color-icon"); - const mutedColor = useThemeColor("--color-foreground-muted"); - const subtleColor = useThemeColor("--color-subtle"); const stageLabel = resolveMobileStageLabel(Constants.expoConfig?.extra?.appVariant); - const titleOffset = brandTitleOffset(props.nativeLeadingItem === true); + const titleOffset = brandTitleOffset(); return ( - + Code - + {stageLabel} @@ -97,31 +59,13 @@ export function renderCompactBrandTitle() { return ; } -export function renderCompactBrandHeaderItems(): NativeStackHeaderItem[] { - return [ - { - element: , - hidesSharedBackground: true, - type: "custom", - }, - ]; -} - export function getCompactBrandHeaderOptions( fallbackTitleStyle?: NativeStackNavigationOptions["headerTitleStyle"], ): NativeStackNavigationOptions { - if (Platform.OS === "ios" && NATIVE_LIQUID_GLASS_SUPPORTED) { - return { - headerTitle: "Threads", - headerTitleStyle: { color: "transparent", fontSize: 18, fontWeight: "800" }, - title: "Threads", - unstable_headerLeftItems: renderCompactBrandHeaderItems, - }; - } - return { headerTitle: renderCompactBrandTitle, headerTitleStyle: fallbackTitleStyle, title: "Threads", + unstable_headerLeftItems: undefined, }; } diff --git a/apps/mobile/src/components/ComposerAttachmentButton.tsx b/apps/mobile/src/components/ComposerAttachmentButton.tsx new file mode 100644 index 000000000000..1af72d8883d7 --- /dev/null +++ b/apps/mobile/src/components/ComposerAttachmentButton.tsx @@ -0,0 +1,55 @@ +import type { MenuAction } from "@react-native-menu/menu"; +import { Pressable } from "react-native"; + +import { SymbolView } from "./AppSymbol"; +import { ControlPillMenu } from "./ControlPill"; + +const ATTACHMENT_MENU_ACTIONS: MenuAction[] = [ + { id: "photos", title: "Photo Library", image: "photo" }, + { id: "files", title: "Choose Files", image: "folder" }, +]; + +export function ComposerAttachmentButton(props: { + readonly disabled?: boolean; + readonly supportsFiles: boolean; + readonly onPickMedia: () => Promise; + readonly onPickFiles: () => Promise; +}) { + const button = ( + void props.onPickMedia()} + > + + + ); + + if (props.disabled || !props.supportsFiles) { + return button; + } + + return ( + { + if (nativeEvent.event === "photos") { + void props.onPickMedia(); + } else if (nativeEvent.event === "files") { + void props.onPickFiles(); + } + }} + > + {button} + + ); +} diff --git a/apps/mobile/src/components/ComposerAttachmentStrip.tsx b/apps/mobile/src/components/ComposerAttachmentStrip.tsx index 0621285c03e0..16f0d422af78 100644 --- a/apps/mobile/src/components/ComposerAttachmentStrip.tsx +++ b/apps/mobile/src/components/ComposerAttachmentStrip.tsx @@ -1,16 +1,33 @@ import { SymbolView } from "../components/AppSymbol"; -import { Image, Pressable, ScrollView, View } from "react-native"; -import { useThemeColor } from "../lib/useThemeColor"; +import { videoMimeType } from "@t3tools/shared/video"; +import { useEffect, useRef, useState } from "react"; +import { Alert, Image, Pressable, ScrollView, View } from "react-native"; -import type { DraftComposerImageAttachment } from "../lib/composerImages"; +import { AppText as Text } from "./AppText"; +import type { DraftComposerAttachment, DraftComposerFileAttachment } from "../lib/composerImages"; +import { VideoAttachmentTile } from "./VideoAttachmentTile"; +import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview"; +import { PresentationSource } from "./NativePresentation"; +import type { FilePreviewSource } from "./FilePreviewModal"; +import { isPdfFile } from "../lib/filePreview"; +import type { EnvironmentId } from "@t3tools/contracts"; +import { + retryComposerAttachmentUpload, + useComposerAttachmentUploadState, +} from "../state/composer-attachment-uploads"; export interface ComposerAttachmentStripProps { - /** Attachment images to display. */ - readonly attachments: ReadonlyArray; - /** Called when the user taps the remove button on an image. */ + readonly environmentId?: EnvironmentId; + /** Attachments to display. */ + readonly attachments: ReadonlyArray; + /** Called when the user removes an attachment. */ readonly onRemove: (imageId: string) => void; - /** Called when the user taps on an image thumbnail to preview it. */ - readonly onPressImage?: (previewUri: string) => void; + /** Called when the user taps an image or PDF to preview it. */ + readonly onPressPreview?: (source: FilePreviewSource) => void; + readonly onPressVideo?: ( + attachment: DraftComposerFileAttachment, + sourceIdentifier: string, + ) => void; /** Image thumbnail size in points. Defaults to 72. */ readonly imageSize?: number; /** Border radius of each image thumbnail. Defaults to 16. */ @@ -19,12 +36,203 @@ export interface ComposerAttachmentStripProps { readonly removeButtonPlacement?: "overlay" | "gutter"; } +type ComposerAttachmentThumbnailProps = { + readonly environmentId?: EnvironmentId; + readonly attachment: DraftComposerAttachment; + readonly size: number; + readonly borderRadius: number; + readonly compact?: boolean; + readonly onPressPreview?: (source: FilePreviewSource) => void; + readonly onPressVideo?: ( + attachment: DraftComposerFileAttachment, + sourceIdentifier: string, + ) => void; +}; + +export function ComposerAttachmentThumbnail(props: ComposerAttachmentThumbnailProps) { + const upload = useComposerAttachmentUploadState(props.environmentId, props.attachment.id); + return ( + + + {upload && upload.status !== "ready" ? ( + + props.environmentId && + retryComposerAttachmentUpload(props.environmentId, props.attachment.id) + } + className="absolute bottom-0.5 left-0.5 flex-row items-center gap-0.5 rounded-full bg-black/70 px-1 py-0.5" + > + + {!props.compact ? ( + + {upload.status === "failed" ? "Retry" : `${Math.floor(upload.progress * 100)}%`} + + ) : null} + + ) : null} + + ); +} + +function ComposerAttachmentContent(props: ComposerAttachmentThumbnailProps) { + const { attachment } = props; + const style = { width: props.size, height: props.size, borderRadius: props.borderRadius }; + if (attachment.type === "image") { + const sourceIdentifier = `draft-image:${attachment.id}`; + return ( + + + props.onPressPreview?.({ + kind: "image", + uri: attachment.dataUrl, + name: attachment.name, + sourceIdentifier, + }) + } + > + + + + ); + } + const onPressVideo = props.onPressVideo; + if (onPressVideo && videoMimeType(attachment) !== null) { + return ( + + ); + } + const canPreview = isPdfFile(attachment) && props.onPressPreview !== undefined; + const sourceIdentifier = `draft-file:${attachment.id}`; + return ( + + + props.onPressPreview?.({ + kind: "pdf", + name: attachment.name, + attachment, + sourceIdentifier, + }) + } + className={ + props.compact + ? "items-center justify-center bg-subtle" + : "items-center justify-center gap-1 bg-subtle px-2" + } + style={style} + > + + {!props.compact ? ( + + {attachment.name} + + ) : null} + + + ); +} + +function ComposerVideoAttachment(props: { + readonly attachment: DraftComposerFileAttachment; + readonly size: number; + readonly borderRadius: number; + readonly compact?: boolean; + readonly onPressVideo: ( + attachment: DraftComposerFileAttachment, + sourceIdentifier: string, + ) => void; +}) { + const { attachment } = props; + const sourceIdentifier = `draft:${attachment.id}`; + const style = { width: props.size, height: props.size, borderRadius: props.borderRadius }; + const shareRef = useRef(null); + const [sharing, setSharing] = useState(false); + useEffect( + () => () => { + shareRef.current?.abort(); + shareRef.current = null; + }, + [], + ); + + const onShare = () => { + if (shareRef.current) return; + const controller = new AbortController(); + shareRef.current = controller; + setSharing(true); + void (async () => { + const preview = await loadLocalAttachmentPreview(attachment, controller.signal); + if (!preview) return; + try { + await preview.share(controller.signal, sourceIdentifier); + } finally { + preview.dispose(); + } + })() + .catch((error: unknown) => { + if (!controller.signal.aborted) { + Alert.alert( + "Could not share video", + error instanceof Error ? error.message : "Try again.", + ); + } + }) + .finally(() => { + if (shareRef.current === controller) { + shareRef.current = null; + setSharing(false); + } + }); + }; + + return ( + props.onPressVideo(attachment, sourceIdentifier)} + onShare={onShare} + disabled={sharing} + style={style} + /> + ); +} + /** - * A horizontally-scrollable strip of image attachment thumbnails with remove - * buttons. Used by both the thread composer and the new-task draft screen. + * Attachment thumbnails used by the thread composer and the new-task draft screen. */ export function ComposerAttachmentStrip(props: ComposerAttachmentStripProps) { - const subtleBg = useThemeColor("--color-subtle"); const size = props.imageSize ?? 72; const radius = props.imageBorderRadius ?? 16; const removeButtonPlacement = props.removeButtonPlacement ?? "overlay"; @@ -42,29 +250,23 @@ export function ComposerAttachmentStrip(props: ComposerAttachmentStripProps) { className="grow-0" > - {props.attachments.map((image) => ( + {props.attachments.map((attachment) => ( - props.onPressImage!(image.previewUri) : undefined} - > - - + props.onRemove(image.id)} + onPress={() => props.onRemove(attachment.id)} > {props.iconNode} ) : props.icon ? ( - + ) : null} )} @@ -111,8 +113,13 @@ export function ComposerToolbarRow(props: { export function ComposerToolbarScroller(props: { readonly children: ReactNode; - readonly fadeOpaque: string; - readonly fadeTransparent: string; + readonly align?: "start" | "end"; + /** Only for non-Uniwind surfaces such as the native terminal palette. */ + readonly fadeOpaque?: string; + /** Only for non-Uniwind surfaces such as the native terminal palette. */ + readonly fadeTransparent?: string; + /** Semantic Uniwind surface behind the toolbar. Defaults to card. */ + readonly fadeSurface?: "card" | "sheet"; readonly contentPaddingRight?: number; }) { const [metrics, setMetrics] = useState({ @@ -161,6 +168,8 @@ export function ComposerToolbarScroller(props: { showsHorizontalScrollIndicator={false} contentContainerStyle={{ alignItems: "center", + flexGrow: props.align === "end" ? 1 : undefined, + justifyContent: props.align === "end" ? "flex-end" : undefined, gap: COMPOSER_TOOLBAR_GAP, paddingLeft: 0, paddingRight: props.contentPaddingRight ?? 1, @@ -170,27 +179,37 @@ export function ComposerToolbarScroller(props: { {scrollEdges.showLeftFade ? ( ) : null} {scrollEdges.showRightFade ? ( ) : null} @@ -198,6 +217,46 @@ export function ComposerToolbarScroller(props: { ); } +export function ComposerActionButton(props: { + readonly accessibilityLabel: string; + readonly disabled?: boolean; + readonly icon: ComponentProps["name"]; + readonly onPress: () => void; + readonly variant?: "primary" | "danger"; +}) { + return ( + + + + + + ); +} + export function ComposerToolbarButton(props: { readonly icon?: ComponentProps["name"]; readonly iconNode?: ReactNode; @@ -214,30 +273,16 @@ export function ComposerToolbarButton(props: { readonly className?: string; readonly style?: StyleProp; }) { - const { themeAppearance } = useAppearancePreferences(); - const isDarkMode = themeAppearance === "dark"; - const iconColor = useThemeColor("--color-icon"); - const iconSubtle = useThemeColor("--color-icon-subtle"); - const primaryFg = useThemeColor("--color-primary-foreground"); - const dangerFg = useThemeColor("--color-danger-foreground"); const variant = props.variant ?? "default"; const isCircle = !props.label && props.showChevron === false; - const defaultBorderColor = useThemeColor("--color-border-subtle"); - const activeBorderColor = useThemeColor("--color-border"); - const filledBorderColor = - variant === "danger" - ? themeColorWithAlpha(String(dangerFg), 0.14) - : props.disabled - ? defaultBorderColor - : themeColorWithAlpha(String(primaryFg), 0.18); - const iconTintColor = + const iconTintClassName = variant === "primary" ? props.disabled - ? iconSubtle - : primaryFg + ? "accent-icon-subtle" + : "accent-primary-foreground" : variant === "danger" - ? dangerFg - : iconColor; + ? "accent-danger-foreground" + : "accent-icon"; return ( [ { - borderColor: - variant === "default" - ? props.active - ? activeBorderColor - : defaultBorderColor - : filledBorderColor, - borderWidth: 1, maxWidth: props.maxWidth, minWidth: props.minWidth, opacity: props.disabled ? 0.55 : pressed ? 0.72 : 1, - shadowColor: "#000", - shadowOffset: { width: 0, height: isDarkMode ? 3 : 2 }, - shadowOpacity: props.disabled ? 0 : isDarkMode ? 0.24 : 0.08, - shadowRadius: isDarkMode ? 10 : 8, }, props.style, ]} @@ -286,7 +330,12 @@ export function ComposerToolbarButton(props: { {props.iconNode ? ( {props.iconNode} ) : props.icon ? ( - + ) : null} {props.label ? ( ) : null} {props.showChevron === false ? null : ( - + )} ); diff --git a/apps/mobile/src/components/ConfirmDialogHost.tsx b/apps/mobile/src/components/ConfirmDialogHost.tsx index 81daa3d6a2da..521c5e36c32f 100644 --- a/apps/mobile/src/components/ConfirmDialogHost.tsx +++ b/apps/mobile/src/components/ConfirmDialogHost.tsx @@ -1,7 +1,6 @@ import { useCallback, useEffect, useState } from "react"; import { Modal, Pressable, View } from "react-native"; -import { useThemeColor } from "../lib/useThemeColor"; import { cn } from "../lib/cn"; import { AppText } from "./AppText"; @@ -35,8 +34,6 @@ export function showConfirmDialog(request: ConfirmDialogRequest): void { */ export function ConfirmDialogHost() { const [request, setRequest] = useState(null); - const pressedOverlay = useThemeColor("--color-subtle"); - useEffect(() => { presentRequest = setRequest; return () => { @@ -76,8 +73,7 @@ export function ConfirmDialogHost() { @@ -88,8 +84,7 @@ export function ConfirmDialogHost() { & { + readonly iconColor?: ColorValue; + readonly destructiveIconColor?: ColorValue; + }) { + const actions = useMemo( + () => + withMenuActionIconColors(props.actions, { + icon: iconColor, + destructiveIcon: destructiveIconColor, + }), + [props.actions, iconColor, destructiveIconColor], + ); + return ; + }, + { + iconColor: { fromClassName: "iconColorClassName", styleProperty: "accentColor" }, + destructiveIconColor: { + fromClassName: "destructiveIconColorClassName", + styleProperty: "accentColor", + }, + }, +); + export function ControlPill(props: { readonly icon?: ComponentProps["name"]; readonly iconNode?: ReactNode; @@ -49,18 +79,14 @@ export function ControlPill(props: { props.onPress?.(); }; - const iconColor = useThemeColor("--color-icon"); - const iconSubtle = useThemeColor("--color-icon-subtle"); - const primaryFg = useThemeColor("--color-primary-foreground"); - const dangerFg = useThemeColor("--color-danger-foreground"); - const iconTintColor = + const iconTintClassName = variant === "primary" ? props.disabled - ? iconSubtle - : primaryFg + ? "accent-icon-subtle" + : "accent-primary-foreground" : variant === "danger" - ? dangerFg - : iconColor; + ? "accent-danger-foreground" + : "accent-icon"; const isCircle = variant === "circle" || variant === "danger" || (variant === "primary" && !props.label); @@ -101,7 +127,12 @@ export function ControlPill(props: { {props.iconNode ? ( {props.iconNode} ) : props.icon ? ( - + ) : null} {props.label ? {props.label} : null} @@ -120,6 +151,8 @@ export function ControlPillMenu( ) { const { themeAppearance } = useAppearancePreferences(); const isDarkMode = themeAppearance === "dark"; + const menuPress = useRef({ isPreparing: false, isOpen: false, suppressPress: false }); + const pendingPress = useRef<(() => void) | null>(null); if (Platform.OS === "android") { // Long-press menus keep their child interactive: the child element gets @@ -161,24 +194,67 @@ export function ControlPillMenu( const { className: _className, ...menuProps } = props; let children = menuProps.children; - // In long-press mode the wrapped pressable still receives the touch (the - // patched MenuView button is touch-transparent) and RN's Fabric touch - // handler is never cancelled by the in-tree UIContextMenuInteraction, so a - // bare onPress would fire on finger-up even after the menu opened — and - // also on a long press released just under the menu threshold. A dispatched - // onLongPress makes Pressability swallow the release, so holds past 350ms - // (below the ~500ms context-menu threshold) can only open the menu, never - // tap through. if (props.shouldOpenOnLongPress && isValidElement(children)) { - const child = children as ReactElement<{ onLongPress?: () => void; delayLongPress?: number }>; + const child = children as ReactElement>; children = cloneElement(child, { - onLongPress: child.props.onLongPress ?? (() => undefined), - delayLongPress: child.props.delayLongPress ?? 350, + onTouchStart: (event) => { + // Reset for a new touch, not onPressIn, which also fires when a + // finger moves out of the row and back during the same gesture. + menuPress.current.isPreparing = false; + menuPress.current.suppressPress = menuPress.current.isOpen; + pendingPress.current = null; + child.props.onTouchStart?.(event); + }, + onPress: (event) => { + // Accessibility clicks have no touch identifier and must not inherit + // cancellation from a previous physical gesture. + const isTouch = typeof event.nativeEvent.identifier === "number"; + if (isTouch ? menuPress.current.suppressPress : menuPress.current.isOpen) { + return; + } + if (isTouch && menuPress.current.isPreparing) { + // A release can arrive between native menu preparation and display. + // Let UIKit's display/cancel callback decide this press's outcome. + event.persist(); + pendingPress.current = () => child.props.onPress?.(event); + return; + } + child.props.onPress?.(event); + }, }); + menuProps.onMenuInteractionStart = () => { + menuPress.current.isPreparing = true; + props.onMenuInteractionStart?.(); + }; + menuProps.onOpenMenu = () => { + menuPress.current.isPreparing = false; + menuPress.current.isOpen = true; + menuPress.current.suppressPress = true; + pendingPress.current = null; + props.onOpenMenu?.(); + }; + menuProps.onCloseMenu = () => { + menuPress.current.isPreparing = false; + menuPress.current.isOpen = false; + // Keep this gesture cancelled even if dismissal precedes finger-up. + // A separate JS long-press timer would also swallow holds that never + // open the native menu. + const press = pendingPress.current; + pendingPress.current = null; + props.onCloseMenu?.(); + if (!menuPress.current.suppressPress) { + press?.(); + } + }; } return ( - + {children} - + ); } diff --git a/apps/mobile/src/components/ErrorBanner.tsx b/apps/mobile/src/components/ErrorBanner.tsx index 76e06edcd16f..6c12c9bdd823 100644 --- a/apps/mobile/src/components/ErrorBanner.tsx +++ b/apps/mobile/src/components/ErrorBanner.tsx @@ -3,10 +3,8 @@ import { View } from "react-native"; import { AppText as Text } from "./AppText"; export function ErrorBanner(props: { readonly message: string }) { return ( - - - {props.message} - + + {props.message} ); } diff --git a/apps/mobile/src/components/FilePreview.ios.tsx b/apps/mobile/src/components/FilePreview.ios.tsx new file mode 100644 index 000000000000..c2f5a6d72cc7 --- /dev/null +++ b/apps/mobile/src/components/FilePreview.ios.tsx @@ -0,0 +1,55 @@ +import { requireNativeModule } from "expo"; +import { useEffect, useEffectEvent, useId } from "react"; +import { Alert } from "react-native"; + +import type { ResolvedFilePreviewSource } from "./FilePreviewModal"; +import { MediaImagePreview } from "./MediaImagePreview"; + +const NativeControls = requireNativeModule<{ + presentFile( + uri: string, + name: string, + sourceIdentifier: string, + identifier: string, + ): Promise; + dismissFile(identifier: string): Promise; +}>("T3NativeControls"); + +function NativeFilePreview(props: { + readonly source: ResolvedFilePreviewSource; + readonly onRequestClose: () => void; +}) { + const { uri, name, sourceIdentifier } = props.source; + const identifier = useId(); + const onRequestClose = useEffectEvent(props.onRequestClose); + + useEffect(() => { + let canceled = false; + void NativeControls.presentFile(uri, name ?? "Preview", sourceIdentifier ?? "", identifier) + .catch(() => { + if (!canceled) { + Alert.alert("Could not open preview", "The file could not be loaded. Please try again."); + } + }) + .finally(() => { + if (!canceled) onRequestClose(); + }); + return () => { + canceled = true; + void NativeControls.dismissFile(identifier).catch(() => undefined); + }; + }, [uri, name, sourceIdentifier, identifier]); + + return null; +} + +export function FilePreview(props: { + readonly source: ResolvedFilePreviewSource; + readonly onRequestClose: () => void; +}) { + return props.source.kind === "image" && props.source.actionsSource ? ( + + ) : ( + + ); +} diff --git a/apps/mobile/src/components/FilePreview.tsx b/apps/mobile/src/components/FilePreview.tsx new file mode 100644 index 000000000000..8240c4a6ad38 --- /dev/null +++ b/apps/mobile/src/components/FilePreview.tsx @@ -0,0 +1,54 @@ +import { useEffect, useEffectEvent } from "react"; +import { Alert } from "react-native"; +import ImageViewing from "react-native-image-viewing"; + +import { downloadAndShareAttachment, shareLocalAttachment } from "../lib/attachmentDownload"; +import type { ResolvedFilePreviewSource } from "./FilePreviewModal"; +import { MediaImagePreview } from "./MediaImagePreview"; + +function PdfPreview(props: { + readonly source: ResolvedFilePreviewSource; + readonly onRequestClose: () => void; +}) { + const { uri, name } = props.source; + const onRequestClose = useEffectEvent(props.onRequestClose); + useEffect(() => { + const controller = new AbortController(); + const input = { + attachment: { name: name ?? "Document.pdf", mimeType: "application/pdf" }, + signal: controller.signal, + }; + // Android's system chooser supplies the installed PDF apps. + const opened = + uri.startsWith("file:") || uri.startsWith("content:") + ? shareLocalAttachment({ ...input, uri }) + : downloadAndShareAttachment({ ...input, url: uri }); + void opened + .catch(() => { + if (!controller.signal.aborted) Alert.alert("Could not open PDF", "Please try again."); + }) + .finally(() => { + if (!controller.signal.aborted) onRequestClose(); + }); + return () => controller.abort(); + }, [uri, name]); + return null; +} + +export function FilePreview(props: { + readonly source: ResolvedFilePreviewSource; + readonly onRequestClose: () => void; +}) { + if (props.source.kind === "pdf") return ; + if (props.source.actionsSource) return ; + return ( + + ); +} diff --git a/apps/mobile/src/components/FilePreviewModal.tsx b/apps/mobile/src/components/FilePreviewModal.tsx new file mode 100644 index 000000000000..c8a6d4291947 --- /dev/null +++ b/apps/mobile/src/components/FilePreviewModal.tsx @@ -0,0 +1,101 @@ +import { useIsFocused } from "@react-navigation/native"; +import type { AssetResource, EnvironmentId } from "@t3tools/contracts"; +import { useEffect, useEffectEvent, useState } from "react"; +import { Alert, Keyboard } from "react-native"; + +import type { DraftComposerFileAttachment } from "../lib/composerImages"; +import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview"; +import type { MediaActionsSource } from "../lib/mediaActions"; +import { useAssetUrlState } from "../state/assets"; +import { usePreparedConnection } from "../state/session"; +import { FilePreview } from "./FilePreview"; + +export interface ResolvedFilePreviewSource { + readonly kind: "image" | "pdf"; + readonly uri: string; + readonly name?: string; + readonly sourceIdentifier?: string; + readonly srcFragment?: string; + readonly actionsSource?: MediaActionsSource; +} + +export type FilePreviewSource = Omit & + ( + | { readonly uri: string } + | { readonly attachment: DraftComposerFileAttachment } + | { readonly environmentId: EnvironmentId; readonly resource: AssetResource } + ); + +function ResolvedFilePreview(props: { + readonly source: FilePreviewSource; + readonly onRequestClose: () => void; +}) { + const { source } = props; + const environmentId = "environmentId" in source ? source.environmentId : null; + const connection = usePreparedConnection(environmentId); + const asset = useAssetUrlState(environmentId, "resource" in source ? source.resource : null); + // Keep the original URL through dismissal; a refreshed signature must not reopen the viewer. + const [uri, setUri] = useState("uri" in source ? source.uri : null); + const onRequestClose = useEffectEvent(props.onRequestClose); + const failed = + environmentId !== null && + uri === null && + (connection._tag === "None" || asset._tag === "Failure"); + useEffect(() => Keyboard.dismiss(), []); + useEffect(() => { + if (uri === null && asset._tag === "Success") setUri(asset.url + (source.srcFragment ?? "")); + }, [uri, asset, source.srcFragment]); + useEffect(() => { + if (!failed) return; + Alert.alert( + "Could not open preview", + connection._tag === "None" + ? "Reconnect to this environment and try again." + : "The file could not be loaded. It may have been moved or deleted.", + ); + onRequestClose(); + }, [failed, connection._tag]); + useEffect(() => { + if (!("attachment" in source)) return; + const controller = new AbortController(); + let release: (() => void) | undefined; + void loadLocalAttachmentPreview(source.attachment, controller.signal) + .then((file) => { + if (!file) return; + if (controller.signal.aborted) { + file.dispose(); + return; + } + release = file.dispose; + setUri(file.uri); + }) + .catch(() => { + if (controller.signal.aborted) return; + Alert.alert("Could not open preview", "Attach the file again and retry."); + onRequestClose(); + }); + return () => { + controller.abort(); + release?.(); + }; + }, [source]); + + return uri === null ? null : ( + + ); +} + +export function FilePreviewModal(props: { + readonly source: FilePreviewSource | null; + readonly onRequestClose: () => void; +}) { + const isFocused = useIsFocused(); + const hasSource = props.source !== null; + const onRequestClose = useEffectEvent(props.onRequestClose); + useEffect(() => { + if (!isFocused && hasSource) onRequestClose(); + }, [isFocused, hasSource]); + + if (!props.source || !isFocused) return null; + return ; +} diff --git a/apps/mobile/src/components/GlassSafeAreaView.tsx b/apps/mobile/src/components/GlassSafeAreaView.tsx deleted file mode 100644 index 8f91d61031bc..000000000000 --- a/apps/mobile/src/components/GlassSafeAreaView.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import type { ReactNode } from "react"; -import { View, type StyleProp, type ViewStyle } from "react-native"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { useThemeColor } from "../lib/useThemeColor"; - -import { GlassSurface } from "./GlassSurface"; - -export interface GlassSafeAreaViewProps { - readonly leftSlot?: ReactNode; - readonly centerSlot?: ReactNode; - readonly rightSlot?: ReactNode; - readonly style?: StyleProp; -} - -export function GlassSafeAreaView({ - leftSlot, - centerSlot, - rightSlot, - style, -}: GlassSafeAreaViewProps) { - const insets = useSafeAreaInsets(); - const headerColor = useThemeColor("--color-header"); - const headerBorderColor = useThemeColor("--color-header-border"); - const glassTint = useThemeColor("--color-glass-tint"); - const headerPaddingTop = insets.top + 16; - const surfaceStyle = { - borderRadius: 0, - backgroundColor: headerColor, - borderBottomWidth: 1, - borderBottomColor: headerBorderColor, - } as const; - - return ( - - - - {leftSlot} - {centerSlot} - {rightSlot} - - - - ); -} diff --git a/apps/mobile/src/components/GlassSurface.tsx b/apps/mobile/src/components/GlassSurface.tsx index add1c3b5e7c8..577c43aa890a 100644 --- a/apps/mobile/src/components/GlassSurface.tsx +++ b/apps/mobile/src/components/GlassSurface.tsx @@ -1,46 +1,55 @@ import { GlassView, isGlassEffectAPIAvailable } from "expo-glass-effect"; -import type { ReactNode } from "react"; +import type { ReactNode, Ref } from "react"; import { Platform, + useColorScheme, View, type ColorValue, type StyleProp, type ViewProps, type ViewStyle, } from "react-native"; -import { useThemeColor } from "../lib/useThemeColor"; -import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; +import { withUniwind } from "uniwind"; -interface GlassSurfaceProps extends Omit { +import { cn } from "../lib/cn"; + +// Explicit mappings keep the native glassEffectStyle enum out of style-array conversion. +const ThemedGlassView = withUniwind(GlassView, { + style: { fromClassName: "className" }, + tintColor: { fromClassName: "tintColorClassName", styleProperty: "accentColor" }, +}); + +interface GlassSurfaceProps extends ViewProps { + readonly ref?: Ref; readonly children: ReactNode; readonly glassEffectStyle?: "clear" | "regular" | "none"; readonly tintColor?: ColorValue; + readonly tintColorClassName?: string; readonly chrome?: "default" | "none"; /** Styling used only when native Liquid Glass is unavailable. */ readonly fallbackStyle?: StyleProp; + /** Uniwind styling used only when native Liquid Glass is unavailable. */ + readonly fallbackClassName?: string; } export function GlassSurface({ + ref, children, glassEffectStyle = "regular", chrome = "default", tintColor, + tintColorClassName, fallbackStyle, + fallbackClassName, + className, style, ...props }: GlassSurfaceProps) { - const { themeAppearance } = useAppearancePreferences(); - const isDarkMode = themeAppearance === "dark"; - const borderColor = useThemeColor("--color-border"); - const glassSurface = useThemeColor("--color-glass-surface"); - const glassTint = useThemeColor("--color-glass-tint"); + const isDarkMode = useColorScheme() === "dark"; const supportsGlass = Platform.OS === "ios" && isGlassEffectAPIAvailable(); const surfaceStyle: ViewStyle = { borderRadius: 32, overflow: "hidden", - borderWidth: chrome === "none" ? 0 : 1, - borderColor: chrome === "none" ? "transparent" : borderColor, - backgroundColor: chrome === "none" ? "transparent" : glassSurface, shadowColor: chrome === "none" ? "transparent" : "#000000", shadowOpacity: chrome === "none" ? 0 : isDarkMode ? 0.22 : 0.08, shadowRadius: chrome === "none" ? 0 : 28, @@ -59,20 +68,41 @@ export function GlassSurface({ if (supportsGlass) { return ( - {children} - + ); } return ( - + {children} ); diff --git a/apps/mobile/src/components/LoadingScreen.tsx b/apps/mobile/src/components/LoadingScreen.tsx index 275381a9c94f..456a347d365d 100644 --- a/apps/mobile/src/components/LoadingScreen.tsx +++ b/apps/mobile/src/components/LoadingScreen.tsx @@ -1,6 +1,5 @@ import { ActivityIndicator, StatusBar, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { useThemeColor } from "../lib/useThemeColor"; import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; import { AppText as Text } from "./AppText"; @@ -11,17 +10,12 @@ export function LoadingScreen(props: { readonly messagePlacement?: "above-spinner" | "below-spinner"; }) { const { themeAppearance: colorScheme } = useAppearancePreferences(); - const screenBg = useThemeColor("--color-screen"); const insets = useSafeAreaInsets(); const messagePlacement = props.messagePlacement ?? "below-spinner"; return ( - + {messagePlacement === "above-spinner" ? ( diff --git a/apps/mobile/src/components/MediaActionsMenu.tsx b/apps/mobile/src/components/MediaActionsMenu.tsx new file mode 100644 index 000000000000..a4b44e85258f --- /dev/null +++ b/apps/mobile/src/components/MediaActionsMenu.tsx @@ -0,0 +1,43 @@ +import { MenuView } from "@react-native-menu/menu"; +import type { ReactElement } from "react"; +import { Platform, View, type PressableProps } from "react-native"; + +import type { useMediaActions } from "../lib/mediaActions"; +import { SymbolView } from "./AppSymbol"; +import { ControlPillMenu } from "./ControlPill"; + +export function MediaActionsMenu(props: { + readonly media: ReturnType; + readonly inModal?: boolean; + readonly children?: ReactElement; +}) { + if (props.media.actions.length === 0) return props.children ?? null; + // Android's normal anchored menu lives in the app-root portal, behind native modals. + const nativeAndroidMenu = props.inModal && Platform.OS === "android"; + const Menu = nativeAndroidMenu ? MenuView : ControlPillMenu; + return ( + ({ + id, + title, + attributes: { disabled: disabled ?? false }, + }))} + onPressAction={({ nativeEvent }) => { + props.media.actions.find(({ id }) => id === nativeEvent.event)?.run(); + }} + > + {props.children ?? ( + + + + )} + + ); +} diff --git a/apps/mobile/src/components/MediaImagePreview.tsx b/apps/mobile/src/components/MediaImagePreview.tsx new file mode 100644 index 000000000000..5bdc9140ddc9 --- /dev/null +++ b/apps/mobile/src/components/MediaImagePreview.tsx @@ -0,0 +1,61 @@ +import { createContext, useContext } from "react"; +import { Pressable, View } from "react-native"; +import ImageViewing from "react-native-image-viewing"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import { useMediaActions } from "../lib/mediaActions"; +import { AppText } from "./AppText"; +import { SymbolView } from "./AppSymbol"; +import type { ResolvedFilePreviewSource } from "./FilePreviewModal"; +import { MediaActionsMenu } from "./MediaActionsMenu"; +import { MediaSourceCaption } from "./MediaSourceCaption"; + +type MediaImagePreviewProps = { + readonly source: ResolvedFilePreviewSource; + readonly onRequestClose: () => void; +}; + +const ImagePreviewContext = createContext(null); + +function ImagePreviewHeader() { + const props = useContext(ImagePreviewContext)!; + const insets = useSafeAreaInsets(); + const mediaActions = useMediaActions(props.source.actionsSource, props.onRequestClose); + return ( + + + + {props.source.name ?? "Image"} + + + + + + + + + ); +} + +/** Chat and workspace media retain source actions on both platforms; other files use native previews. */ +export function MediaImagePreview(props: MediaImagePreviewProps) { + return ( + + + + ); +} diff --git a/apps/mobile/src/components/MediaSourceCaption.tsx b/apps/mobile/src/components/MediaSourceCaption.tsx new file mode 100644 index 000000000000..76c24290d347 --- /dev/null +++ b/apps/mobile/src/components/MediaSourceCaption.tsx @@ -0,0 +1,19 @@ +import { ScrollView } from "react-native"; + +import { AppText } from "./AppText"; + +/** Keep the original reference readable without letting long URLs displace the preview. */ +export function MediaSourceCaption(props: { readonly source: string | undefined }) { + if (!props.source) return null; + return ( + + + {props.source} + + + ); +} diff --git a/apps/mobile/src/components/MediaVideoPlayer.tsx b/apps/mobile/src/components/MediaVideoPlayer.tsx new file mode 100644 index 000000000000..a065f75e1396 --- /dev/null +++ b/apps/mobile/src/components/MediaVideoPlayer.tsx @@ -0,0 +1,187 @@ +import { useIsFocused } from "@react-navigation/native"; +import { useEvent } from "expo"; +import { useVideoPlayer, VideoView } from "expo-video"; +import { useEffect, useEffectEvent, useRef, useState } from "react"; +import { ActivityIndicator, AppState, Pressable, View } from "react-native"; + +import { AppText } from "./AppText"; +import { SymbolView } from "./AppSymbol"; +import { VideoThumbnailImage } from "./VideoThumbnailImage"; +import { useMediaActions, type MediaActionsSource } from "../lib/mediaActions"; +import { MediaActionsMenu } from "./MediaActionsMenu"; + +/** Loads only after Play or opening the viewer. Source replacement never starts playback itself. */ +function LoadedMediaVideo(props: { + readonly uri: string; + readonly resolvePlaybackUri?: () => Promise; + readonly playRequested: boolean; + readonly paused: boolean; +}) { + const focused = useIsFocused(); + const active = useRef(focused && AppState.currentState === "active"); + const [attempt, setAttempt] = useState(0); + // Expo's Android player also reports completed playback as idle. + const [loadState, setLoadState] = useState<"pending" | "complete" | "error">("pending"); + const player = useVideoPlayer(null, (player) => { + player.staysActiveInBackground = false; + player.bufferOptions = { preferredForwardBufferDuration: 5 }; + }); + const { status } = useEvent(player, "statusChange", { status: player.status }); + const loadSource = useEffectEvent(async (signal: AbortSignal) => { + const uri = props.resolvePlaybackUri ? await props.resolvePlaybackUri() : props.uri; + if (signal.aborted) return; + if (uri === null) throw new Error("Video unavailable"); + player.pause(); + await player.replaceAsync({ uri, contentType: "progressive" }); + if (!signal.aborted && props.playRequested && active.current) player.play(); + }); + + useEffect(() => { + active.current = focused && !props.paused && AppState.currentState === "active"; + if (!active.current) player.pause(); + const subscription = AppState.addEventListener("change", (state) => { + active.current = focused && !props.paused && state === "active"; + if (!active.current) player.pause(); + }); + return () => subscription.remove(); + }, [focused, player, props.paused]); + + useEffect(() => { + const controller = new AbortController(); + setLoadState("pending"); + // A renewed signature is used on Retry, not as a reason to reset the native player. + void loadSource(controller.signal).then( + () => { + if (!controller.signal.aborted) setLoadState("complete"); + }, + () => { + if (!controller.signal.aborted) setLoadState("error"); + }, + ); + return () => controller.abort(); + }, [player, props.playRequested, attempt]); + + return ( + + + {loadState === "error" || (loadState === "complete" && status === "error") ? ( + + Video unavailable + setAttempt((value) => value + 1)} + className="min-h-11 justify-center px-4" + > + Retry + + + ) : loadState === "pending" || status === "loading" ? ( + + + + ) : null} + + ); +} + +interface MediaVideoPlayerProps { + readonly uri: string | null; + readonly resolvePlaybackUri?: () => Promise; + readonly name: string; + readonly thumbnailKey: string; + readonly thumbnailVisible?: boolean; + readonly unavailable?: boolean; + readonly expanded?: boolean; + readonly paused?: boolean; + readonly onExpand?: () => void; + readonly actionsSource?: MediaActionsSource; +} + +function MediaVideoPlayerContent(props: MediaVideoPlayerProps) { + const mediaActions = useMediaActions(props.actionsSource); + const [playbackUri, setPlaybackUri] = useState(props.expanded ? props.uri : null); + // Keep an opened player mounted while signing or reconnecting temporarily has no usable URL. + if (playbackUri === null && props.expanded && props.uri !== null) setPlaybackUri(props.uri); + + return ( + + {playbackUri ? ( + + ) : ( + setPlaybackUri(props.uri)} + className="flex-1 items-center justify-center gap-2 px-4" + > + {!props.unavailable ? ( + + ) : null} + {props.unavailable ? ( + Video unavailable + ) : props.uri === null ? ( + + ) : ( + <> + + + + + {props.name} + + + )} + + )} + {props.onExpand ? ( + { + setPlaybackUri(null); + props.onExpand?.(); + }} + className="absolute right-1 top-1 min-h-11 min-w-11 items-center justify-center rounded-md bg-black/60 px-2" + > + Expand + + ) : null} + {props.actionsSource ? ( + + + + ) : null} + + ); +} + +export function MediaVideoPlayer(props: MediaVideoPlayerProps) { + return ; +} diff --git a/apps/mobile/src/components/MediaVideoPreviewModal.tsx b/apps/mobile/src/components/MediaVideoPreviewModal.tsx new file mode 100644 index 000000000000..6c231194701c --- /dev/null +++ b/apps/mobile/src/components/MediaVideoPreviewModal.tsx @@ -0,0 +1,96 @@ +import { useEffect } from "react"; +import { Keyboard, Modal, Pressable, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import { useMediaActions } from "../lib/mediaActions"; +import { MediaActionsMenu } from "./MediaActionsMenu"; +import { + mediaVideoPreviewUri, + mediaVideoThumbnailKey, + type MediaVideoPreviewSource, +} from "../lib/videoPreviewSource"; +import { useAssetUrlState, useRefreshAssetUrl } from "../state/assets"; +import { usePreparedConnection } from "../state/session"; +import { AppText } from "./AppText"; +import { SymbolView } from "./AppSymbol"; +import { MediaVideoPlayer } from "./MediaVideoPlayer"; +import { MediaSourceCaption } from "./MediaSourceCaption"; + +/** Media files stream in place. A client-side copy is made only for an explicit share. */ +export function MediaVideoPreviewModal(props: { + readonly source: MediaVideoPreviewSource; + readonly onRequestClose: () => void; +}) { + const { source } = props; + const insets = useSafeAreaInsets(); + const environmentId = "environmentId" in source ? source.environmentId : null; + const connection = usePreparedConnection(environmentId); + const asset = useAssetUrlState(environmentId, "resource" in source ? source.resource : null); + const refreshAssetUrl = useRefreshAssetUrl( + environmentId, + "resource" in source ? source.resource : null, + ); + const resolvePlaybackUri = + "resource" in source + ? async () => mediaVideoPreviewUri(source, await refreshAssetUrl()) + : undefined; + const uri = mediaVideoPreviewUri(source, asset._tag === "Success" ? asset.url : null); + const mediaActions = useMediaActions(source.actionsSource, props.onRequestClose); + const unavailable = + uri === null && + environmentId !== null && + (connection._tag === "None" || asset._tag === "Failure"); + + useEffect(() => Keyboard.dismiss(), []); + return ( + + + + + {source.name} + + + + + + + + + + + {mediaActions.sharing ? "Opening share sheet..." : "Save or share video"} + + + + + ); +} diff --git a/apps/mobile/src/components/NativePresentation.ios.tsx b/apps/mobile/src/components/NativePresentation.ios.tsx new file mode 100644 index 000000000000..b93578dde602 --- /dev/null +++ b/apps/mobile/src/components/NativePresentation.ios.tsx @@ -0,0 +1,12 @@ +import { requireNativeView } from "expo"; +import type { ComponentType } from "react"; +import type { PresentationSourceProps } from "./NativePresentation"; + +const NativeSource: ComponentType = requireNativeView( + "T3NativeControls", + "PresentationSource", +); + +export function PresentationSource(props: PresentationSourceProps) { + return ; +} diff --git a/apps/mobile/src/components/NativePresentation.tsx b/apps/mobile/src/components/NativePresentation.tsx new file mode 100644 index 000000000000..d48b8839540c --- /dev/null +++ b/apps/mobile/src/components/NativePresentation.tsx @@ -0,0 +1,13 @@ +import type { ReactElement } from "react"; +import { View, type ViewProps } from "react-native"; + +export interface PresentationSourceProps extends ViewProps { + readonly children: ReactElement; + /** Stable across remounts so dismissal can find a recycled attachment thumbnail. */ + readonly identifier: string; +} + +/** Registers the view as an iOS zoom or share-sheet origin. */ +export function PresentationSource({ identifier: _identifier, ...props }: PresentationSourceProps) { + return ; +} diff --git a/apps/mobile/src/components/PierreEntryIcon.tsx b/apps/mobile/src/components/PierreEntryIcon.tsx index 9cb6898fb9ec..cb73f5b7b180 100644 --- a/apps/mobile/src/components/PierreEntryIcon.tsx +++ b/apps/mobile/src/components/PierreEntryIcon.tsx @@ -3,7 +3,6 @@ import { Image, type ImageStyle, type StyleProp } from "react-native"; import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons"; import { resolveMarkdownFileIcon } from "@t3tools/mobile-markdown-text/links"; -import { useThemeColor } from "../lib/useThemeColor"; export function PierreEntryIcon(props: { readonly path: string; @@ -12,9 +11,15 @@ export function PierreEntryIcon(props: { readonly style?: StyleProp; }) { const size = props.size ?? 16; - const folderColor = useThemeColor("--color-icon-subtle"); if (props.kind === "directory") { - return ; + return ( + + ); } return ( diff --git a/apps/mobile/src/components/ProjectFavicon.tsx b/apps/mobile/src/components/ProjectFavicon.tsx index c4297f24b096..c60709baf4c9 100644 --- a/apps/mobile/src/components/ProjectFavicon.tsx +++ b/apps/mobile/src/components/ProjectFavicon.tsx @@ -7,7 +7,6 @@ import { getProjectFaviconCacheKey, isProjectFaviconFallbackUrl, } from "@t3tools/shared/projectFavicon"; -import { useThemeColor } from "../lib/useThemeColor"; import { useAssetUrl } from "../state/assets"; import { beginProjectFaviconRequest, @@ -62,7 +61,6 @@ function ProjectFaviconImage(props: { readonly projectTitle: string; readonly size: number; }) { - const iconMuted = useThemeColor("--color-icon-subtle"); const faviconRequest = useMemo( () => createProjectFaviconRequest(props.cacheKey, props.faviconUrl), [props.cacheKey, props.faviconUrl], @@ -97,7 +95,7 @@ function ProjectFaviconImage(props: { ) : null} diff --git a/apps/mobile/src/components/SourceControlIcon.tsx b/apps/mobile/src/components/SourceControlIcon.tsx index b1d4918037ce..3b371c021adc 100644 --- a/apps/mobile/src/components/SourceControlIcon.tsx +++ b/apps/mobile/src/components/SourceControlIcon.tsx @@ -1,4 +1,7 @@ import Svg, { Defs, LinearGradient, Path, Stop } from "react-native-svg"; +import { withUniwind } from "uniwind"; + +const ThemedSvg = withUniwind(Svg); export type SourceControlIconKind = "github" | "gitlab" | "bitbucket" | "azure-devops"; @@ -6,20 +9,28 @@ export function SourceControlIcon(props: { readonly kind: SourceControlIconKind; readonly size?: number; readonly color?: string; + readonly colorClassName?: string; }) { const size = props.size ?? 18; switch (props.kind) { case "github": return ( - + - + ); case "gitlab": return ( diff --git a/apps/mobile/src/components/T3Wordmark.tsx b/apps/mobile/src/components/T3Wordmark.tsx index 81106557c66b..5f64effffc18 100644 --- a/apps/mobile/src/components/T3Wordmark.tsx +++ b/apps/mobile/src/components/T3Wordmark.tsx @@ -1,11 +1,18 @@ import type { ColorValue } from "react-native"; import Svg, { Path } from "react-native-svg"; +import { withUniwind } from "uniwind"; + +const ThemedPath = withUniwind(Path); /** * The "T3" brand mark, matching the desktop sidebar's T3Wordmark SVG * (apps/web Sidebar.tsx). Width derives from the viewBox aspect ratio. */ -export function T3Wordmark(props: { readonly height: number; readonly color: ColorValue }) { +export function T3Wordmark(props: { + readonly height: number; + readonly color?: ColorValue; + readonly colorClassName?: string; +}) { const aspectRatio = 94.3941 / 56.96; return ( - ); diff --git a/apps/mobile/src/components/ThemedSwitch.tsx b/apps/mobile/src/components/ThemedSwitch.tsx index 270ee084e428..5b4603fd1201 100644 --- a/apps/mobile/src/components/ThemedSwitch.tsx +++ b/apps/mobile/src/components/ThemedSwitch.tsx @@ -1,21 +1,19 @@ import { Platform, Switch, type SwitchProps } from "react-native"; -import { useThemeColor } from "../lib/useThemeColor"; - export function ThemedSwitch(props: SwitchProps) { - const activeTrack = String(useThemeColor("--color-switch-active-track")); - const inactiveTrack = String(useThemeColor("--color-switch-inactive-track")); - const activeThumb = String(useThemeColor("--color-switch-active-thumb")); - const inactiveThumb = String(useThemeColor("--color-switch-inactive-thumb")); - return ( ); } diff --git a/apps/mobile/src/components/VideoAttachmentMenu.tsx b/apps/mobile/src/components/VideoAttachmentMenu.tsx new file mode 100644 index 000000000000..301d6503a508 --- /dev/null +++ b/apps/mobile/src/components/VideoAttachmentMenu.tsx @@ -0,0 +1,53 @@ +import type { ReactElement } from "react"; +import { Platform, type PressableProps } from "react-native"; + +import { ControlPillMenu } from "./ControlPill"; +import { PresentationSource } from "./NativePresentation"; + +export function VideoAttachmentMenu(props: { + readonly sourceIdentifier: string; + readonly onOpen: () => void; + readonly onShare?: () => void; + readonly disabled?: boolean; + readonly children: ReactElement; +}) { + return ( + { + if (!props.disabled) props.onOpen(); + }} + accessibilityActions={props.onShare ? [{ name: "share", label: "Save or share video" }] : []} + onAccessibilityAction={({ nativeEvent }) => { + if (nativeEvent.actionName === "share" && !props.disabled) props.onShare?.(); + }} + > + {Platform.OS === "ios" && props.onShare ? ( + { + if (nativeEvent.event === "share") props.onShare?.(); + }} + > + {props.children} + + ) : ( + props.children + )} + + ); +} diff --git a/apps/mobile/src/components/VideoAttachmentTile.tsx b/apps/mobile/src/components/VideoAttachmentTile.tsx new file mode 100644 index 000000000000..6f582ac5f005 --- /dev/null +++ b/apps/mobile/src/components/VideoAttachmentTile.tsx @@ -0,0 +1,66 @@ +import { Platform, Pressable, View, type StyleProp, type ViewStyle } from "react-native"; + +import { cn } from "../lib/cn"; +import type { DraftComposerFileAttachment } from "../lib/composerImages"; +import { SymbolView } from "./AppSymbol"; +import { AppText } from "./AppText"; +import { VideoAttachmentMenu } from "./VideoAttachmentMenu"; +import { VideoThumbnailImage } from "./VideoThumbnailImage"; + +export function VideoAttachmentTile(props: { + readonly name: string; + readonly sourceIdentifier: string; + readonly thumbnailSource: string | DraftComposerFileAttachment | null; + readonly compact?: boolean; + readonly onPress: (sourceIdentifier: string) => void; + readonly onShare?: () => void; + readonly disabled?: boolean; + readonly className?: string; + readonly style?: StyleProp; +}) { + return ( + props.onPress(props.sourceIdentifier)} + onShare={props.onShare} + disabled={props.disabled} + > + props.onPress(props.sourceIdentifier)} + className={cn("items-center justify-center overflow-hidden bg-black/80", props.className)} + style={props.style} + > + + + + + {!props.compact ? ( + + + {props.name} + + + ) : null} + + + ); +} diff --git a/apps/mobile/src/components/VideoPreviewModal.ios.tsx b/apps/mobile/src/components/VideoPreviewModal.ios.tsx new file mode 100644 index 000000000000..88a1b5191dd0 --- /dev/null +++ b/apps/mobile/src/components/VideoPreviewModal.ios.tsx @@ -0,0 +1,125 @@ +import { useIsFocused } from "@react-navigation/native"; +import { videoMimeType } from "@t3tools/shared/video"; +import { requireNativeModule } from "expo"; +import { useEffect, useEffectEvent, useId, useState } from "react"; +import { Alert, Keyboard } from "react-native"; + +import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview"; +import { useAssetUrlState } from "../state/assets"; +import { usePreparedConnection } from "../state/session"; +import type { AttachmentVideoPreviewSource, VideoPreviewSource } from "../lib/videoPreviewSource"; +import { MediaVideoPreviewModal } from "./MediaVideoPreviewModal"; + +export type { VideoPreviewSource } from "../lib/videoPreviewSource"; + +const NativeControls = requireNativeModule<{ + presentVideo( + uri: string, + title: string, + sourceIdentifier: string, + identifier: string, + ): Promise; + dismissVideo(identifier: string): Promise; +}>("T3NativeControls"); + +function NativeVideoPreview(props: { + readonly source: AttachmentVideoPreviewSource; + readonly onRequestClose: () => void; +}) { + const { source } = props; + const { attachment } = source; + const identifier = useId(); + const onRequestClose = useEffectEvent(props.onRequestClose); + const environmentId = source.type === "remote" ? source.environmentId : null; + const preparedConnection = usePreparedConnection(environmentId); + const mimeType = videoMimeType(attachment) ?? attachment.mimeType; + const assetUrl = useAssetUrlState( + environmentId, + source.type === "remote" + ? { _tag: "attachment", attachmentId: attachment.id, fileName: attachment.name, mimeType } + : null, + ); + const [playbackUrl, setPlaybackUrl] = useState(() => + assetUrl._tag === "Success" ? assetUrl.url : null, + ); + const loadError = + source.type === "remote" && playbackUrl === null + ? preparedConnection._tag === "None" + ? "Reconnect to this environment and open the video again." + : assetUrl._tag === "Failure" + ? "Could not load this video. Check the connection and try again." + : null + : null; + + useEffect(() => Keyboard.dismiss(), []); + useEffect(() => { + if (playbackUrl === null && assetUrl._tag === "Success") setPlaybackUrl(assetUrl.url); + }, [playbackUrl, assetUrl]); + useEffect(() => { + if (!loadError) return; + Alert.alert("Could not open video", loadError); + onRequestClose(); + }, [loadError]); + + useEffect(() => { + if (source.type === "remote" && playbackUrl === null) return; + const controller = new AbortController(); + let ready = false; + void (async () => { + const file = + source.type === "local" + ? await loadLocalAttachmentPreview(source.attachment, controller.signal) + : null; + if (source.type === "local" && !file) return; + try { + if (controller.signal.aborted) return; + ready = true; + await NativeControls.presentVideo( + file?.uri ?? playbackUrl!, + attachment.name, + source.sourceIdentifier ?? "", + identifier, + ); + if (!controller.signal.aborted) onRequestClose(); + } finally { + // Native completion follows dismissal, so local playback keeps its file lease. + file?.dispose(); + } + })().catch((error: unknown) => { + if (controller.signal.aborted) return; + Alert.alert( + "Could not open video", + ready + ? "This video couldn't be loaded or played. Check the connection, or touch and hold the attachment to save or share the original." + : error instanceof Error + ? error.message + : "Could not load this video.", + ); + onRequestClose(); + }); + return () => { + controller.abort(); + void NativeControls.dismissVideo(identifier).catch(() => undefined); + }; + }, [source, attachment.name, playbackUrl, identifier]); + + return null; +} + +export function VideoPreviewModal(props: { + readonly source: VideoPreviewSource | null; + readonly onRequestClose: () => void; +}) { + const isFocused = useIsFocused(); + const hasSource = props.source !== null; + const onRequestClose = useEffectEvent(props.onRequestClose); + useEffect(() => { + if (!isFocused && hasSource) onRequestClose(); + }, [isFocused, hasSource]); + + if (!props.source || !isFocused) return null; + if (props.source.type === "media") { + return ; + } + return ; +} diff --git a/apps/mobile/src/components/VideoPreviewModal.tsx b/apps/mobile/src/components/VideoPreviewModal.tsx new file mode 100644 index 000000000000..cc56b3952b75 --- /dev/null +++ b/apps/mobile/src/components/VideoPreviewModal.tsx @@ -0,0 +1,254 @@ +import { useIsFocused } from "@react-navigation/native"; +import { videoMimeType } from "@t3tools/shared/video"; +import { useEvent } from "expo"; +import { useVideoPlayer, VideoView } from "expo-video"; +import { useEffect, useRef, useState } from "react"; +import { + ActivityIndicator, + AppState, + Keyboard, + Modal, + Pressable, + StyleSheet, + View, +} from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import { + downloadAttachmentForPreview, + type AttachmentPreviewFile, +} from "../lib/attachmentDownload"; +import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview"; +import type { AttachmentVideoPreviewSource, VideoPreviewSource } from "../lib/videoPreviewSource"; +import { useAssetUrlState } from "../state/assets"; +import { usePreparedConnection } from "../state/session"; +import { SymbolView } from "./AppSymbol"; +import { AppText } from "./AppText"; +import { MediaVideoPreviewModal } from "./MediaVideoPreviewModal"; + +export type { VideoPreviewSource } from "../lib/videoPreviewSource"; + +function VideoPlayback(props: { readonly file: AttachmentPreviewFile }) { + const player = useVideoPlayer(props.file.uri, (player) => { + player.staysActiveInBackground = false; + if (AppState.currentState === "active") player.play(); + }); + const { status } = useEvent(player, "statusChange", { status: player.status }); + const shareControllerRef = useRef(null); + const [sharing, setSharing] = useState(false); + const [shareError, setShareError] = useState(null); + + useEffect( + () => () => { + shareControllerRef.current?.abort(); + shareControllerRef.current = null; + }, + [], + ); + + const onShare = () => { + if (shareControllerRef.current) return; + player.pause(); + const controller = new AbortController(); + shareControllerRef.current = controller; + setSharing(true); + setShareError(null); + void props.file + .share(controller.signal) + .catch((error: unknown) => { + if (!controller.signal.aborted) { + setShareError(error instanceof Error ? error.message : "Could not share this video."); + } + }) + .finally(() => { + if (shareControllerRef.current === controller) { + shareControllerRef.current = null; + setSharing(false); + } + }); + }; + + return ( + <> + + {status === "error" ? ( + + This video couldn't be played on this device. You can save or share the original file. + + ) : ( + <> + + {status === "loading" ? ( + + ) : null} + + )} + + + + {sharing ? "Opening share sheet..." : "Save or share video"} + + + {shareError ? ( + + {shareError} + + ) : null} + + ); +} + +function OpenVideoPreviewModal(props: { + readonly source: AttachmentVideoPreviewSource; + readonly onRequestClose: () => void; +}) { + const { source } = props; + const { attachment } = source; + const insets = useSafeAreaInsets(); + const environmentId = source.type === "remote" ? source.environmentId : null; + const preparedConnection = usePreparedConnection(environmentId); + const fileUri = source.type === "local" ? source.attachment.fileUri : null; + const mimeType = videoMimeType(attachment) ?? attachment.mimeType; + const assetUrl = useAssetUrlState( + environmentId, + source.type === "remote" + ? { _tag: "attachment", attachmentId: attachment.id, fileName: attachment.name, mimeType } + : null, + ); + const [downloadUrl, setDownloadUrl] = useState(null); + const [file, setFile] = useState(null); + const [failure, setFailure] = useState(null); + + useEffect(() => Keyboard.dismiss(), []); + useEffect(() => { + if (environmentId !== null && downloadUrl === null && assetUrl._tag === "Success") { + setDownloadUrl(assetUrl.url); + } + }, [environmentId, downloadUrl, assetUrl]); + + useEffect(() => { + if (source.type === "remote" && downloadUrl === null) return; + const controller = new AbortController(); + let preview: AttachmentPreviewFile | null = null; + setFile(null); + setFailure(null); + const loading = + source.type === "local" + ? loadLocalAttachmentPreview(source.attachment, controller.signal) + : downloadAttachmentForPreview({ + url: downloadUrl!, + attachment: { name: attachment.name, mimeType }, + signal: controller.signal, + }); + void loading.then( + (loaded) => { + if (controller.signal.aborted) { + loaded?.dispose(); + return; + } + preview = loaded; + setFile(loaded); + }, + (error: unknown) => { + if (!controller.signal.aborted) { + setFailure(error instanceof Error ? error.message : "Could not load this video."); + } + }, + ); + return () => { + controller.abort(); + preview?.dispose(); + }; + }, [source.type, environmentId, attachment.id, attachment.name, mimeType, fileUri, downloadUrl]); + + const loadError = + failure ?? + (environmentId !== null && downloadUrl === null + ? preparedConnection._tag === "None" + ? "This environment is disconnected. Reconnect and open the video again." + : assetUrl._tag === "Failure" + ? "Could not load this video. Check the connection to this environment and try again." + : null + : null); + + return ( + + + + + {attachment.name} + + + + + + {file ? ( + + ) : ( + + {loadError ? ( + + {loadError} + + ) : ( + <> + + Loading video... + + )} + + )} + + + ); +} + +export function VideoPreviewModal(props: { + readonly source: VideoPreviewSource | null; + readonly onRequestClose: () => void; +}) { + const isFocused = useIsFocused(); + const hasSource = props.source !== null; + useEffect(() => { + if (!isFocused && hasSource) props.onRequestClose(); + }, [isFocused, hasSource, props.onRequestClose]); + const { source } = props; + if (source === null || !isFocused) return null; + if (source.type === "media") { + return ; + } + const key = + source.type === "local" + ? `local:${source.attachment.id}:${source.attachment.fileUri}` + : `remote:${source.environmentId}:${source.attachment.id}`; + return ; +} diff --git a/apps/mobile/src/components/VideoThumbnailImage.tsx b/apps/mobile/src/components/VideoThumbnailImage.tsx new file mode 100644 index 000000000000..cfb8ceeb2aca --- /dev/null +++ b/apps/mobile/src/components/VideoThumbnailImage.tsx @@ -0,0 +1,46 @@ +import { Image } from "expo-image"; +import { useIsFocused } from "@react-navigation/native"; +import type { VideoThumbnail } from "expo-video"; +import { useEffect, useState } from "react"; +import { StyleSheet } from "react-native"; + +import type { DraftComposerFileAttachment } from "../lib/composerImages"; +import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview"; +import { cachedVideoThumbnail, loadVideoThumbnail } from "../lib/videoThumbnails"; + +export function VideoThumbnailImage(props: { + readonly cacheKey: string; + readonly source: string | DraftComposerFileAttachment | null; + readonly contentFit?: "cover" | "contain"; +}) { + const { cacheKey, source } = props; + const isFocused = useIsFocused(); + const [loaded, setLoaded] = useState<{ key: string; thumbnail: VideoThumbnail } | null>(null); + const thumbnail = loaded?.key === cacheKey ? loaded.thumbnail : cachedVideoThumbnail(cacheKey); + + useEffect(() => { + if (!source || !isFocused) return; + const controller = new AbortController(); + void loadVideoThumbnail( + cacheKey, + async (signal) => + typeof source === "string" + ? { uri: source, dispose: () => undefined } + : loadLocalAttachmentPreview(source, signal), + controller.signal, + ).then((thumbnail) => { + if (thumbnail && !controller.signal.aborted) setLoaded({ key: cacheKey, thumbnail }); + }); + return () => controller.abort(); + }, [cacheKey, source, isFocused]); + + return thumbnail ? ( + + ) : null; +} diff --git a/apps/mobile/src/connection/platform.ts b/apps/mobile/src/connection/platform.ts index 8e699e4c24fd..d6b50e50a6a7 100644 --- a/apps/mobile/src/connection/platform.ts +++ b/apps/mobile/src/connection/platform.ts @@ -29,7 +29,7 @@ import { authClientMetadata } from "../lib/authClientMetadata"; import * as Runtime from "../lib/runtime"; import * as MobileStorage from "../persistence/mobile-storage"; import { appAtomRegistry } from "../state/atom-registry"; -import { clearThreadOutboxEnvironment } from "../state/thread-outbox"; +import { clearThreadOutboxEnvironment } from "../state/thread-outbox-removal"; import { clearComposerDraftsEnvironment } from "../state/use-composer-drafts"; import { mobileApplicationActiveWakeup } from "./app-state-wakeups"; import { connectionStorageLayer } from "./storage"; diff --git a/apps/mobile/src/connection/runtime.ts b/apps/mobile/src/connection/runtime.ts index b589c114b926..ee224ce9f6ed 100644 --- a/apps/mobile/src/connection/runtime.ts +++ b/apps/mobile/src/connection/runtime.ts @@ -4,13 +4,18 @@ import { threadSnapshotLoaderLayer } from "@t3tools/client-runtime/state/threads import * as Layer from "effect/Layer"; import { Atom } from "effect/unstable/reactivity"; +import type { FoundationHotModule } from "../lib/foundation-fast-refresh"; +import { hotSwappableAtomRuntime } from "../lib/hot-swappable-atom-runtime"; import { runtimeContextLayer } from "../lib/runtime"; +import { appAtomRegistry } from "../state/atom-registry"; import { mobileBackgroundActivityObserverLayer, mobileBackgroundActivityReporterLayer, } from "./background-activity"; import { connectionPlatformLayer } from "./platform"; +declare const module: { readonly hot?: FoundationHotModule } | undefined; + const providedConnectionPlatformLayer = connectionPlatformLayer.pipe( Layer.provide(runtimeContextLayer), ); @@ -42,4 +47,9 @@ const connectionLayer = mobileBackgroundActivityReporterLayer.pipe( export const connectionAtomRuntime: Atom.AtomRuntime< Layer.Success, Layer.Error -> = Atom.runtime(connectionLayer); +> = hotSwappableAtomRuntime({ + id: "t3.mobile.connection-runtime", + hotModule: typeof module === "undefined" ? undefined : module.hot, + registry: appAtomRegistry, + layer: connectionLayer, +}); diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts index b0f77d7704b5..a2d4261de603 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts @@ -828,18 +828,6 @@ export function unregisterAgentAwarenessConnection(environmentId: EnvironmentId) removeAgentAwarenessConnection(environmentId); } -export function unregisterAllAgentAwarenessConnections(): void { - environmentConnections.clear(); - pushTokenSubscription?.remove(); - pushTokenSubscription = null; - appStateSubscription?.remove(); - appStateSubscription = null; - if (activeLiveActivityRegistrationRetry) { - clearTimeout(activeLiveActivityRegistrationRetry); - activeLiveActivityRegistrationRetry = null; - } -} - export function refreshAgentAwarenessRegistration(): Effect.Effect< void, never, diff --git a/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx index 801862086b90..5b61d302a767 100644 --- a/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx +++ b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx @@ -27,7 +27,7 @@ import { EmptyState } from "../../components/EmptyState"; import { ProjectFavicon } from "../../components/ProjectFavicon"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { relativeTime } from "../../lib/time"; -import { useThemeColor } from "../../lib/useThemeColor"; +import { useUniwindTheme } from "../../lib/useUniwindTheme"; import { ThreadSwipeable } from "../home/thread-swipe-actions"; import { createNativeMailSearchToolbarItem, @@ -70,8 +70,6 @@ function ArchivedThreadsHeader(props: { const navigation = useNavigation(); const insets = useSafeAreaInsets(); const hasCustomFilter = props.selectedEnvironmentId !== null || props.sortOrder !== "newest"; - const searchIconColor = useThemeColor("--color-icon"); - const searchTextColor = useThemeColor("--color-foreground"); const usesNativeChrome = Platform.OS === "ios"; const usesCompactMailToolbar = Platform.OS === "ios" && width < 700 && NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED; @@ -154,7 +152,7 @@ function ArchivedThreadsHeader(props: { @@ -162,7 +160,7 @@ function ArchivedThreadsHeader(props: { @@ -402,9 +400,7 @@ function ArchivedThreadRow(props: { readonly thread: EnvironmentThreadShell; }) { const { width: windowWidth } = useWindowDimensions(); - const cardColor = useThemeColor("--color-card"); - const iconColor = useThemeColor("--color-icon-subtle"); - const separatorColor = useThemeColor("--color-separator"); + const cardColor = useUniwindTheme()["--color-card"]; const timestamp = relativeTime(props.thread.archivedAt ?? props.thread.updatedAt); const subtitle = [props.environmentLabel, props.thread.branch].filter((part): part is string => Boolean(part), @@ -436,14 +432,15 @@ function ArchivedThreadRow(props: { > {() => ( - + @@ -463,7 +460,7 @@ function ArchivedThreadRow(props: { (null); const archiveScrollGesture = useMemo(() => Gesture.Native(), []); - const refreshTint = useThemeColor("--color-icon"); const environmentLabelsById = useMemo( () => new Map( @@ -594,7 +590,7 @@ export function ArchivedThreadsScreen(props: { if (isInitialLoad) { return ( - + Loading archive... ); @@ -610,7 +606,7 @@ export function ArchivedThreadsScreen(props: { title={isFiltered ? "No matching threads" : "No archived threads"} /> ); - }, [isFiltered, isInitialLoad, refreshTint]); + }, [isFiltered, isInitialLoad]); return ( @@ -649,7 +645,7 @@ export function ArchivedThreadsScreen(props: { } renderItem={renderListItem} diff --git a/apps/mobile/src/features/cloud/CloudAuthProvider.test.ts b/apps/mobile/src/features/cloud/CloudAuthProvider.test.ts index 2bc62d2a34ee..5fe74f673141 100644 --- a/apps/mobile/src/features/cloud/CloudAuthProvider.test.ts +++ b/apps/mobile/src/features/cloud/CloudAuthProvider.test.ts @@ -26,6 +26,12 @@ vi.mock("../../connection/catalog", () => ({ }, })); +vi.mock("./cloud-drafts", () => ({ removeCloudEnvironments: {} })); +vi.mock("../../state/use-composer-drafts", () => ({ + getComposerCloudAccountId: vi.fn(async () => null), + restoreCloudComposerDrafts: vi.fn(async () => undefined), +})); + vi.mock("./publicConfig", () => ({ resolveCloudPublicConfig: vi.fn(() => ({ clerk: { publishableKey: null }, diff --git a/apps/mobile/src/features/cloud/CloudAuthProvider.tsx b/apps/mobile/src/features/cloud/CloudAuthProvider.tsx index f7ece97cbaa9..fffdd2343044 100644 --- a/apps/mobile/src/features/cloud/CloudAuthProvider.tsx +++ b/apps/mobile/src/features/cloud/CloudAuthProvider.tsx @@ -5,14 +5,18 @@ import { reportAtomCommandResult, settleAsyncResult, settlePromise, + squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; import * as Effect from "effect/Effect"; import { type ReactNode, useEffect, useRef } from "react"; -import { environmentCatalog } from "../../connection/catalog"; import { runtime } from "../../lib/runtime"; import { appAtomRegistry } from "../../state/atom-registry"; import { useAtomCommand } from "../../state/use-atom-command"; +import { + getComposerCloudAccountId, + restoreCloudComposerDrafts, +} from "../../state/use-composer-drafts"; import { releaseAgentAwarenessRelayTokenProvider, setAgentAwarenessRelayTokenProvider, @@ -20,6 +24,7 @@ import { } from "../agent-awareness/remoteRegistration"; import { clearConnectOnboardingRequest, requestConnectOnboarding } from "./connectOnboarding"; import { resolveCloudPublicConfig, resolveRelayClerkTokenOptions } from "./publicConfig"; +import { removeCloudEnvironments } from "./cloud-drafts"; function resetManagedRelayTokenCache() { return settleAsyncResult(() => @@ -47,7 +52,7 @@ export function activateCloudRelayAccount( function CloudAuthBridge(props: { readonly children: ReactNode }) { const { getToken, isLoaded, isSignedIn, userId } = useAuth({ treatPendingAsSignedOut: false }); - const removeRelayEnvironments = useAtomCommand(environmentCatalog.removeRelayEnvironments, { + const removeRelayEnvironments = useAtomCommand(removeCloudEnvironments, { reportFailure: false, reportDefect: false, }); @@ -81,32 +86,37 @@ function CloudAuthBridge(props: { readonly children: ReactNode }) { clearConnectOnboardingRequest(); } - const queueAccountCleanup = ( + const cleanUpAccount = async ( previous: { readonly userId: string; readonly provider: () => Promise; } | null, + accountId: string | null, ) => { - const previousTransition = accountTransitionRef.current ?? Promise.resolve(); - accountTransitionRef.current = previousTransition.then(async () => { - const cleanup = [ - resetManagedRelayTokenCache(), - removeRelayEnvironments(), - ...(previous - ? [ - settleAsyncResult(() => - runtime.runPromiseExit( - unregisterAgentAwarenessDeviceForCurrentUser(previous.provider), - ), + const removal = await removeRelayEnvironments(accountId); + if (removal._tag !== "Success") throw squashAtomCommandFailure(removal); + const cleanup = [ + resetManagedRelayTokenCache(), + ...(previous + ? [ + settleAsyncResult(() => + runtime.runPromiseExit( + unregisterAgentAwarenessDeviceForCurrentUser(previous.provider), ), - ] - : []), - ]; - const results = await Promise.all(cleanup); - for (const result of results) { - reportAtomCommandResult(result, { label: "cloud account cleanup" }); - } - }); + ), + ] + : []), + ]; + const results = await Promise.all(cleanup); + for (const result of results) { + reportAtomCommandResult(result, { label: "cloud account cleanup" }); + } + }; + const queueAccountCleanup = (previous: typeof previousTokenProviderRef.current) => { + const previousTransition = accountTransitionRef.current ?? Promise.resolve(); + accountTransitionRef.current = previousTransition + .catch(() => {}) + .then(() => cleanUpAccount(previous, previousObservedAccount ?? null)); return accountTransitionRef.current; }; @@ -115,7 +125,9 @@ function CloudAuthBridge(props: { readonly children: ReactNode }) { previousTokenProviderRef.current = null; deactivateCloudRelayAccount(); if (previousObservedAccount !== null) { - void queueAccountCleanup(previous); + void settlePromise(() => queueAccountCleanup(previous)).then((result) => { + reportAtomCommandResult(result, { label: "cloud account cleanup" }); + }); } return; } @@ -133,13 +145,21 @@ function CloudAuthBridge(props: { readonly children: ReactNode }) { } }; const activateAfterTransition = (transition: Promise) => { - void (async () => { - const result = await settlePromise(async () => { - await transition; - activateSession(); - }); - reportAtomCommandResult(result, { label: "cloud account activation" }); + const activation = (async () => { + await transition; + if (cancelled) return; + const storedAccount = await getComposerCloudAccountId(); + if (storedAccount !== null && storedAccount !== userId) { + await cleanUpAccount(null, storedAccount); + } + if (cancelled) return; + await restoreCloudComposerDrafts(userId); + activateSession(); })(); + accountTransitionRef.current = activation; + void settlePromise(() => activation).then((result) => { + reportAtomCommandResult(result, { label: "cloud account activation" }); + }); }; if ( previousObservedAccount !== undefined && @@ -150,7 +170,9 @@ function CloudAuthBridge(props: { readonly children: ReactNode }) { deactivateCloudRelayAccount(); activateAfterTransition(queueAccountCleanup(previous)); } else { - activateAfterTransition(accountTransitionRef.current ?? Promise.resolve()); + // A failed disk write can be retried. The persisted account check above + // still requires cleanup before activating a different account. + activateAfterTransition((accountTransitionRef.current ?? Promise.resolve()).catch(() => {})); } return () => { diff --git a/apps/mobile/src/features/cloud/cloud-drafts.ts b/apps/mobile/src/features/cloud/cloud-drafts.ts new file mode 100644 index 000000000000..bc41b2b41fe0 --- /dev/null +++ b/apps/mobile/src/features/cloud/cloud-drafts.ts @@ -0,0 +1,46 @@ +import { EnvironmentRegistry } from "@t3tools/client-runtime/connection"; +import { createRuntimeCommand } from "@t3tools/client-runtime/state/runtime"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import * as SubscriptionRef from "effect/SubscriptionRef"; + +import { connectionAtomRuntime } from "../../connection/runtime"; +import { archiveCloudComposerDrafts } from "../../state/use-composer-drafts"; + +export class CloudDraftArchiveError extends Schema.TaggedErrorClass()( + "CloudDraftArchiveError", + { + environmentCount: Schema.Number, + hasAccountId: Schema.Boolean, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Could not preserve local drafts for ${this.environmentCount} cloud environments before sign-out.`; + } +} + +export const removeCloudEnvironments = createRuntimeCommand(connectionAtomRuntime, { + label: "cloud:preserve-drafts-and-remove-environments", + execute: Effect.fn("removeCloudEnvironments")(function* (accountId: string | null) { + const registry = yield* EnvironmentRegistry; + const entries = yield* SubscriptionRef.get(registry.entries); + const environmentIds = new Set( + [...entries.values()] + .filter((entry) => entry.target._tag === "RelayConnectionTarget") + .map((entry) => entry.target.environmentId), + ); + // Credentials are already revoked. A failed backup must leave the local + // owners intact so a later sign-in can retry without losing their files. + yield* Effect.tryPromise({ + try: () => archiveCloudComposerDrafts(accountId, environmentIds), + catch: (cause) => + new CloudDraftArchiveError({ + environmentCount: environmentIds.size, + hasAccountId: accountId !== null, + cause, + }), + }); + yield* registry.removeRelayEnvironments(); + }), +}); diff --git a/apps/mobile/src/features/cloud/linkEnvironment.test.ts b/apps/mobile/src/features/cloud/linkEnvironment.test.ts index c75d60d5fdf8..42aa8ffebb61 100644 --- a/apps/mobile/src/features/cloud/linkEnvironment.test.ts +++ b/apps/mobile/src/features/cloud/linkEnvironment.test.ts @@ -4,7 +4,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import { EnvironmentId } from "@t3tools/contracts"; import { RelayMobileClientId } from "@t3tools/contracts/relay"; -import { ManagedRelay } from "@t3tools/client-runtime/relay"; +import { DPOP_UNKNOWN_HINT, ManagedRelay } from "@t3tools/client-runtime/relay"; import { remoteHttpClientLayer } from "@t3tools/client-runtime/rpc"; import { HttpClient } from "effect/unstable/http"; import { MobilePreferencesStore } from "../../persistence/mobile-preferences"; @@ -33,6 +33,19 @@ vi.mock("expo-constants", () => ({ }, })); +vi.mock("expo-device", () => ({ + deviceType: 1, + DeviceType: { + UNKNOWN: 0, + PHONE: 1, + TABLET: 2, + DESKTOP: 3, + TV: 4, + }, + osVersion: "18.4.1", + modelName: "iPhone 15 Pro", +})); + vi.mock("react-native", () => ({ Platform: { OS: "ios", @@ -1076,13 +1089,88 @@ describe("mobile cloud link environment client", () => { ).pipe(Effect.flip); expect(error).toMatchObject({ _tag: "CloudEnvironmentLinkError", - message: - "https://relay.example.test/v1/environments/env-1/connect failed: Relay rejected the DPoP proof.", + message: `https://relay.example.test/v1/environments/env-1/connect failed: Relay rejected the DPoP proof. ${DPOP_UNKNOWN_HINT}`, traceId: "trace-connect", }); }), ); + it.effect( + "presents clock skew as one possible cause when an older environment rejects DPoP", + () => + Effect.gen(function* () { + vi.stubGlobal( + "fetch", + vi.fn((url: string | URL) => { + const value = String(url); + if (value.endsWith("/v1/client/dpop-token")) { + return Promise.resolve( + Response.json(validDpopAccessTokenResponse("environment:connect")), + ); + } + if (value.endsWith("/v1/environments/env-1/connect")) { + return Promise.resolve( + Response.json({ + environmentId: "env-1", + endpoint: { + httpBaseUrl: "https://desktop.example.test/", + wsBaseUrl: "wss://desktop.example.test/ws", + providerKind: "cloudflare_tunnel", + }, + credential: "one-time-cloud-credential", + expiresAt: "2026-05-25T00:05:00.000Z", + }), + ); + } + if (value.endsWith("/.well-known/t3/environment")) { + return Promise.resolve( + Response.json({ + environmentId: "env-1", + label: "Desktop", + platform: { os: "darwin", arch: "arm64" }, + serverVersion: "0.0.0-test", + capabilities: { repositoryIdentity: true }, + }), + ); + } + return Promise.resolve( + Response.json( + { + _tag: "EnvironmentAuthInvalidError", + code: "auth_invalid", + reason: "invalid_credential", + traceId: "trace-environment", + }, + { status: 401 }, + ), + ); + }), + ); + + const error = yield* withCloudServices( + connectCloudEnvironment({ + clerkToken: "clerk-token", + environment: { + environmentId: EnvironmentId.make("env-1"), + label: "Desktop", + endpoint: { + httpBaseUrl: "https://desktop.example.test/", + wsBaseUrl: "wss://desktop.example.test/ws", + providerKind: "cloudflare_tunnel", + }, + linkedAt: "2026-05-25T00:00:00.000Z", + }, + }), + ).pipe(Effect.flip); + + expect(error).toMatchObject({ + _tag: "CloudEnvironmentLinkError", + message: `Could not exchange a managed endpoint DPoP access token. ${DPOP_UNKNOWN_HINT}`, + traceId: "trace-environment", + }); + }), + ); + it.effect("rejects relay connect responses for a different endpoint", () => Effect.gen(function* () { vi.stubGlobal( diff --git a/apps/mobile/src/features/cloud/linkEnvironment.ts b/apps/mobile/src/features/cloud/linkEnvironment.ts index 958827ee492b..c2033117f69d 100644 --- a/apps/mobile/src/features/cloud/linkEnvironment.ts +++ b/apps/mobile/src/features/cloud/linkEnvironment.ts @@ -4,6 +4,7 @@ import * as Schema from "effect/Schema"; import { HttpClient } from "effect/unstable/http"; import { EnvironmentCloudEndpointUnavailableError, + EnvironmentAuthInvalidError, EnvironmentHttpBadRequestError, EnvironmentHttpConflictError, EnvironmentHttpForbiddenError, @@ -17,7 +18,6 @@ import { RelayEnvironmentConnectScope, RelayEnvironmentStatusScope, type RelayDpopAccessTokenScope, - type RelayProtectedError as RelayProtectedErrorType, type RelayClientEnvironmentRecord, type RelayEnvironmentStatusResponse as RelayEnvironmentStatusResponseType, type RelayManagedEndpointProviderKind, @@ -25,7 +25,11 @@ import { import { exchangeRemoteDpopAccessToken } from "@t3tools/client-runtime/authorization"; import { fetchRemoteEnvironmentDescriptor } from "@t3tools/client-runtime/environment"; import { findErrorTraceId } from "@t3tools/client-runtime/errors"; -import { ManagedRelay } from "@t3tools/client-runtime/relay"; +import { + dpopFailureMessage, + ManagedRelay, + relayProtectedErrorMessage, +} from "@t3tools/client-runtime/relay"; import { makeEnvironmentHttpApiClient } from "@t3tools/client-runtime/rpc"; import { authClientMetadata } from "../../lib/authClientMetadata"; @@ -73,18 +77,24 @@ const isEnvironmentCloudApiError = Schema.is( EnvironmentCloudEndpointUnavailableError, ]), ); +const isEnvironmentAuthInvalidError = Schema.is(EnvironmentAuthInvalidError); const MANAGED_ENDPOINT_PROVIDER_KIND = "cloudflare_tunnel" satisfies RelayManagedEndpointProviderKind; -function cloudEnvironmentLinkError(message: string) { +function cloudEnvironmentLinkError(message: string, options?: { readonly dpop?: boolean }) { return (cause: unknown) => { const environmentError = findEnvironmentCloudApiError(cause); const traceId = findErrorTraceId(cause); + const dpopAuthError = options?.dpop ? findEnvironmentAuthInvalidError(cause) : null; + const detail = environmentError + ? `${message.replace(/[.:]$/, "")}: ${environmentError.message}` + : withDevCause(message, cause); return new CloudEnvironmentLinkError({ - message: environmentError - ? `${message.replace(/[.:]$/, "")}: ${environmentError.message}` - : withDevCause(message, cause), + message: + dpopAuthError?.reason === "invalid_credential" + ? dpopFailureMessage(detail, dpopAuthError.dpopFailureReason) + : detail, cause, ...(traceId === null ? {} : { traceId }), }); @@ -117,50 +127,6 @@ function withDevCause(message: string, cause: unknown): string { return detail ? `${message} (${detail})` : message; } -function relayProtectedErrorMessage(error: RelayProtectedErrorType): string { - switch (error._tag) { - case "RelayAuthInvalidError": - switch (error.reason) { - case "missing_bearer": - case "invalid_bearer": - return "Relay rejected the cloud session token."; - case "invalid_dpop": - return "Relay rejected the DPoP proof."; - case "not_authorized": - return "Relay rejected the authenticated request."; - } - case "RelayEnvironmentLinkProofExpiredError": - return "Relay rejected an expired environment link proof."; - case "RelayEnvironmentLinkProofInvalidError": - return `Relay rejected the environment link proof (${error.reason}).`; - case "RelayEnvironmentConnectNotAuthorizedError": - // "Not authorized" covers non-auth causes too; surface the reason so a - // missing link doesn't read as a credential problem. - if (error.reason === "environment_link_not_found") { - return "Relay has no active link for this environment. The environment server may not have re-established its link yet."; - } - return error.reason - ? `Relay rejected the environment connection request (${error.reason}).` - : "Relay rejected the environment connection request."; - case "RelayEnvironmentEndpointUnavailableError": - return `Relay could not reach the environment endpoint (${error.reason}).`; - case "RelayEnvironmentEndpointTimedOutError": - return "Relay timed out while contacting the environment endpoint."; - case "RelayEnvironmentLinkFailedError": - return `Relay could not link the environment (${error.reason}).`; - case "RelayEnvironmentLinkUnavailableError": - return `Relay cannot provision the managed endpoint (${error.reason}).`; - case "RelayEnvironmentLinkLimitExceededError": - return `Relay refused the link: this account already has its maximum of ${error.maxTunnels} managed tunnels. Unlink an environment to free one up.`; - case "RelayAgentActivityPublishProofExpiredError": - return "Relay rejected an expired agent activity publish proof."; - case "RelayAgentActivityPublishProofInvalidError": - return `Relay rejected the agent activity publish proof (${error.reason}).`; - case "RelayInternalError": - return `Relay encountered an internal error (${error.reason}).`; - } -} - function decodedRelayClientError(message: string) { return (cause: ManagedRelay.ManagedRelayClientError) => { const relayError = @@ -185,6 +151,16 @@ function findEnvironmentCloudApiError(cause: unknown): { readonly message: strin return "cause" in cause ? findEnvironmentCloudApiError(cause.cause) : null; } +function findEnvironmentAuthInvalidError(cause: unknown): EnvironmentAuthInvalidError | null { + if (isEnvironmentAuthInvalidError(cause)) { + return cause; + } + if (typeof cause !== "object" || cause === null) { + return null; + } + return "cause" in cause ? findEnvironmentAuthInvalidError(cause.cause) : null; +} + function requireRelayUrl(): Effect.Effect { const relayUrl = readRelayUrl(); return relayUrl @@ -560,7 +536,9 @@ const connectRelayManagedEnvironment = Effect.fn("mobile.cloud.connectRelayManag clientMetadata: authClientMetadata(), }).pipe( Effect.mapError( - cloudEnvironmentLinkError("Could not exchange a managed endpoint DPoP access token."), + cloudEnvironmentLinkError("Could not exchange a managed endpoint DPoP access token.", { + dpop: true, + }), ), ); const pairingUrl = new URL(connect.endpoint.httpBaseUrl); diff --git a/apps/mobile/src/features/cloud/managedRelayState.ts b/apps/mobile/src/features/cloud/managedRelayState.ts index eec1e3410e6e..8c41d74841e7 100644 --- a/apps/mobile/src/features/cloud/managedRelayState.ts +++ b/apps/mobile/src/features/cloud/managedRelayState.ts @@ -4,10 +4,7 @@ import { managedRelaySessionAtom, readManagedRelaySnapshotState, } from "@t3tools/client-runtime/relay"; -import type { - RelayClientEnvironmentRecord, - RelayEnvironmentStatusResponse, -} from "@t3tools/contracts/relay"; +import type { RelayClientEnvironmentRecord } from "@t3tools/contracts/relay"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { useCallback, useEffect } from "react"; @@ -26,10 +23,6 @@ const EMPTY_ENVIRONMENTS_ATOM = Atom.make( AsyncResult.success>([]), ).pipe(Atom.keepAlive, Atom.withLabel("managed-relay:mobile:environments:null")); -const EMPTY_ENVIRONMENT_STATUS_ATOM = Atom.make( - AsyncResult.initial(false), -).pipe(Atom.keepAlive, Atom.withLabel("managed-relay:mobile:environment-status:null")); - export function useManagedRelayEnvironments() { const session = useAtomValue(managedRelaySessionAtom); const accountId = session?.accountId ?? null; @@ -59,39 +52,6 @@ export function useManagedRelayEnvironments() { }; } -export function useManagedRelayEnvironmentStatus(environment: RelayClientEnvironmentRecord) { - const session = useAtomValue(managedRelaySessionAtom); - const accountId = session?.accountId ?? null; - const atom = accountId - ? managedRelayQueryManager.environmentStatusAtom({ accountId, environment }) - : EMPTY_ENVIRONMENT_STATUS_ATOM; - const result = useAtomValue(atom); - const snapshot = readManagedRelaySnapshotState(result); - useEffect(() => { - if (snapshot.error) { - console.error("[t3-cloud] Relay environment status failed", { - environmentId: environment.environmentId, - message: snapshot.error, - traceId: snapshot.errorTraceId, - }); - } - }, [environment.environmentId, snapshot.error, snapshot.errorTraceId]); - const refresh = useCallback(() => { - if (accountId) { - managedRelayQueryManager.refreshEnvironmentStatus(appAtomRegistry, { - accountId, - environment, - }); - } - }, [accountId, environment]); - - return { - ...snapshot, - accountId, - refresh, - }; -} - export function refreshManagedRelayEnvironments(): void { const session = appAtomRegistry.get(managedRelaySessionAtom); if (session) { diff --git a/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx b/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx index 6da73eaeb1fa..4c840636c9fc 100644 --- a/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx +++ b/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx @@ -18,7 +18,6 @@ import { AppText as Text } from "../../components/AppText"; import { ThemedSwitch } from "../../components/ThemedSwitch"; import { cn } from "../../lib/cn"; import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; -import { useThemeColor } from "../../lib/useThemeColor"; import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types"; import { availableCloudEnvironmentPresentation } from "../cloud/cloudEnvironmentPresentation"; import { hasCloudPublicConfig } from "../cloud/publicConfig"; @@ -78,7 +77,6 @@ function CloudEnvironmentRowsContent( props: CloudEnvironmentRowsProps & { readonly discoveryAvailable?: boolean }, ) { const controller = useConnectionController(); - const iconColor = useThemeColor("--color-icon"); const discoveryAvailable = props.discoveryAvailable ?? true; const availableCloudEnvironments = discoveryAvailable ? (props.showcaseAvailableEnvironments ?? controller.availableRelayEnvironments) @@ -118,12 +116,12 @@ function CloudEnvironmentRowsContent( className="h-9 w-9 items-center justify-center rounded-full bg-subtle active:opacity-70 disabled:opacity-50" > {controller.relayDiscovery.isRefreshing ? ( - + ) : ( )} @@ -158,7 +156,7 @@ function CloudEnvironmentRowsContent( ) : controller.relayDiscovery.isRefreshing ? ( - + Loading linked cloud environments. @@ -275,7 +273,6 @@ function CloudEnvironmentRowShell(props: { readonly statusText?: string; readonly value: boolean; }) { - const chevron = useThemeColor("--color-chevron"); const isRetrying = props.connectionState === "connecting" || props.connectionState === "reconnecting"; const shouldPulse = isRetrying; @@ -287,7 +284,7 @@ function CloudEnvironmentRowShell(props: { traceId: props.connectionErrorTraceId, }); const statusClassName = props.connectionError - ? "text-rose-500 dark:text-rose-400" + ? "text-adaptive-rose-500-400" : "text-foreground-muted"; const [errorMeasurement, setErrorMeasurement] = useState<{ readonly text: string; @@ -377,7 +374,7 @@ function CloudEnvironmentRowShell(props: { - + Copy trace ID ); diff --git a/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx b/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx index 03a0eb5025f6..86ebc6c11ab7 100644 --- a/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx +++ b/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx @@ -7,7 +7,6 @@ import { AsyncResult } from "effect/unstable/reactivity"; import { useCallback, useState } from "react"; import { Alert, Pressable, View } from "react-native"; import Animated, { FadeIn, FadeOut, LinearTransition } from "react-native-reanimated"; -import { useThemeColor } from "../../lib/useThemeColor"; import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; import { cn } from "../../lib/cn"; @@ -36,10 +35,6 @@ export function ConnectionEnvironmentRow(props: { }) { const [label, setLabel] = useState(props.environment.environmentLabel); const [url, setUrl] = useState(props.environment.displayUrl); - - const mutedColor = useThemeColor("--color-icon-subtle"); - const primaryFg = useThemeColor("--color-primary-foreground"); - const dangerFg = useThemeColor("--color-danger-foreground"); const statusLabel = connectionStatusLabel(props.environment); const statusTraceId = props.environment.connectionErrorTraceId; const hasConnectionFailure = props.environment.connectionError !== null; @@ -85,7 +80,7 @@ export function ConnectionEnvironmentRow(props: { - + Save @@ -188,7 +188,7 @@ export function ConnectionEnvironmentRow(props: { @@ -197,7 +197,12 @@ export function ConnectionEnvironmentRow(props: { className="h-[42px] w-[42px] items-center justify-center rounded-[14px] border border-danger-border bg-danger active:opacity-70" onPress={() => props.onRemove(props.environment.environmentId)} > - + diff --git a/apps/mobile/src/features/connection/ConnectionSheetButton.tsx b/apps/mobile/src/features/connection/ConnectionSheetButton.tsx index f88e32874451..fe26c66a355e 100644 --- a/apps/mobile/src/features/connection/ConnectionSheetButton.tsx +++ b/apps/mobile/src/features/connection/ConnectionSheetButton.tsx @@ -1,6 +1,5 @@ import { SymbolView } from "../../components/AppSymbol"; import { Platform, Pressable } from "react-native"; -import { useThemeColor } from "../../lib/useThemeColor"; import { AppText as Text } from "../../components/AppText"; import { cn } from "../../lib/cn"; @@ -37,11 +36,12 @@ export function ConnectionSheetButton(props: { }) { const tone = props.tone ?? "secondary"; - const primaryFg = useThemeColor("--color-primary-foreground"); - const dangerFg = useThemeColor("--color-danger-foreground"); - const secondaryFg = useThemeColor("--color-secondary-foreground"); - - const textColor = tone === "primary" ? primaryFg : tone === "danger" ? dangerFg : secondaryFg; + const textColorClassName = + tone === "primary" + ? "accent-primary-foreground" + : tone === "danger" + ? "accent-danger-foreground" + : "accent-secondary-foreground"; const primaryShadow = tone === "primary" @@ -79,7 +79,7 @@ export function ConnectionSheetButton(props: { (null); - const headerIconColor = useThemeColor("--color-icon"); + const headerIconColor = useUniwindTheme()["--color-icon"]; const connectDisabled = isSubmitting || hostInput.trim().length === 0; diff --git a/apps/mobile/src/features/connection/ConnectionsRouteScreen.tsx b/apps/mobile/src/features/connection/ConnectionsRouteScreen.tsx index 464477ffc874..88d4e2d4bee7 100644 --- a/apps/mobile/src/features/connection/ConnectionsRouteScreen.tsx +++ b/apps/mobile/src/features/connection/ConnectionsRouteScreen.tsx @@ -5,7 +5,6 @@ import type { EnvironmentId } from "@t3tools/contracts"; import { useCallback, useState } from "react"; import { Platform, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { useThemeColor } from "../../lib/useThemeColor"; import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; import { AppText as Text } from "../../components/AppText"; @@ -24,9 +23,6 @@ export function ConnectionsRouteScreen() { const insets = useSafeAreaInsets(); const hasEnvironments = connectedEnvironments.length > 0; const [expandedId, setExpandedId] = useState(null); - - const accentColor = useThemeColor("--color-icon-muted"); - const handleToggle = useCallback((environmentId: EnvironmentId) => { setExpandedId((prev) => (prev === environmentId ? null : environmentId)); }, []); @@ -89,7 +85,7 @@ export function ConnectionsRouteScreen() { diff --git a/apps/mobile/src/features/connection/EnvironmentConnectionNotice.tsx b/apps/mobile/src/features/connection/EnvironmentConnectionNotice.tsx index ce7e7bec96a7..4bb15fc9872a 100644 --- a/apps/mobile/src/features/connection/EnvironmentConnectionNotice.tsx +++ b/apps/mobile/src/features/connection/EnvironmentConnectionNotice.tsx @@ -7,7 +7,6 @@ import { ActivityIndicator, Pressable, View } from "react-native"; import { AppText as Text } from "../../components/AppText"; import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; -import { useThemeColor } from "../../lib/useThemeColor"; function noticeTitle(phase: EnvironmentConnectionPhase, environmentLabel: string): string { switch (phase) { @@ -55,7 +54,6 @@ export function EnvironmentConnectionNotice(props: { readonly resourceName: string; readonly onRetry: () => void; }) { - const iconColor = String(useThemeColor("--color-icon-muted")); const isRetrying = props.connection.phase === "connecting" || props.connection.phase === "reconnecting"; @@ -63,12 +61,12 @@ export function EnvironmentConnectionNotice(props: { {isRetrying ? ( - + ) : ( )} diff --git a/apps/mobile/src/features/connection/connectionTone.ts b/apps/mobile/src/features/connection/connectionTone.ts index 0de49ceabf6e..51ee592c2ca9 100644 --- a/apps/mobile/src/features/connection/connectionTone.ts +++ b/apps/mobile/src/features/connection/connectionTone.ts @@ -6,38 +6,38 @@ export function connectionTone(state: RemoteClientConnectionState): StatusTone { case "connected": return { label: "Connected", - pillClassName: "bg-emerald-500/12 dark:bg-emerald-500/16", - textClassName: "text-emerald-700 dark:text-emerald-300", + pillClassName: "bg-adaptive-emerald-500-a12-a16", + textClassName: "text-adaptive-emerald-700-300", }; case "reconnecting": return { label: "Reconnecting", - pillClassName: "bg-amber-500/12 dark:bg-amber-500/16", - textClassName: "text-amber-700 dark:text-amber-300", + pillClassName: "bg-adaptive-amber-500-a12-a16", + textClassName: "text-adaptive-amber-700-300", }; case "connecting": return { label: "Connecting", - pillClassName: "bg-sky-500/12 dark:bg-sky-500/16", - textClassName: "text-sky-700 dark:text-sky-300", + pillClassName: "bg-adaptive-sky-500-a12-a16", + textClassName: "text-adaptive-sky-700-300", }; case "error": return { label: "Connection failed", - pillClassName: "bg-rose-500/12 dark:bg-rose-500/16", - textClassName: "text-rose-700 dark:text-rose-300", + pillClassName: "bg-adaptive-rose-500-a12-a16", + textClassName: "text-adaptive-rose-700-300", }; case "offline": return { label: "Offline", - pillClassName: "bg-rose-500/12 dark:bg-rose-500/16", - textClassName: "text-rose-700 dark:text-rose-300", + pillClassName: "bg-adaptive-rose-500-a12-a16", + textClassName: "text-adaptive-rose-700-300", }; case "available": return { label: "Available", - pillClassName: "bg-neutral-500/10 dark:bg-neutral-500/16", - textClassName: "text-neutral-600 dark:text-neutral-300", + pillClassName: "bg-adaptive-neutral-500-a10-a16", + textClassName: "text-adaptive-neutral-600-300", }; } } diff --git a/apps/mobile/src/features/diffs/nativeReviewDiffSurface.test.ts b/apps/mobile/src/features/diffs/nativeReviewDiffSurface.test.ts index 438b50a27ee6..08ab53971b68 100644 --- a/apps/mobile/src/features/diffs/nativeReviewDiffSurface.test.ts +++ b/apps/mobile/src/features/diffs/nativeReviewDiffSurface.test.ts @@ -44,21 +44,6 @@ describe("resolveNativeReviewDiffView", () => { expect(expoMocks.requireNativeView).toHaveBeenCalledWith("T3ReviewDiffSurface"); }); - it("does not fall back to stale legacy native review diff view names", async () => { - globalThis.expo = { - getViewConfig: vi.fn().mockImplementation((moduleName: string) => { - if (moduleName === "T3ReviewDiffView") { - return { validAttributes: {}, directEventTypes: {} }; - } - return null; - }), - } as unknown as typeof globalThis.expo; - expoMocks.requireNativeView.mockReturnValue(nativeView); - const { resolveNativeReviewDiffView } = await import("./nativeReviewDiffSurface"); - expect(resolveNativeReviewDiffView()).toBeNull(); - expect(expoMocks.requireNativeView).not.toHaveBeenCalled(); - }); - it("returns null when the view manager cannot be required", async () => { setExpoViewConfigAvailable(); const cause = new Error("boom"); diff --git a/apps/mobile/src/features/files/FileMarkdownPreview.tsx b/apps/mobile/src/features/files/FileMarkdownPreview.tsx index 8b5892f3a098..b7497debc524 100644 --- a/apps/mobile/src/features/files/FileMarkdownPreview.tsx +++ b/apps/mobile/src/features/files/FileMarkdownPreview.tsx @@ -13,7 +13,7 @@ import { resolveMarkdownFontSizes, resolveNativeMarkdownTypography, } from "../../lib/appearancePreferences"; -import { useThemeColor } from "../../lib/useThemeColor"; +import { useUniwindTheme } from "../../lib/useUniwindTheme"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { hasNativeSelectableMarkdownText, @@ -38,14 +38,15 @@ function useMarkdownPreviewStyles(): MarkdownPreviewStyles { () => resolveNativeMarkdownTypography(appearance.baseFontSize), [appearance.baseFontSize], ); - const body = String(useThemeColor("--color-md-body")); - const strong = String(useThemeColor("--color-md-strong")); - const link = String(useThemeColor("--color-md-link")); - const blockquoteBorder = String(useThemeColor("--color-md-blockquote-border")); - const blockquoteBackground = String(useThemeColor("--color-md-blockquote-bg")); - const codeBackground = String(useThemeColor("--color-md-code-bg")); - const codeText = String(useThemeColor("--color-md-code-text")); - const horizontalRule = String(useThemeColor("--color-md-hr")); + const theme = useUniwindTheme(); + const body = theme["--color-md-body"]; + const strong = theme["--color-md-strong"]; + const link = theme["--color-md-link"]; + const blockquoteBorder = theme["--color-md-blockquote-border"]; + const blockquoteBackground = theme["--color-md-blockquote-bg"]; + const codeBackground = theme["--color-md-code-bg"]; + const codeText = theme["--color-md-code-text"]; + const horizontalRule = theme["--color-md-hr"]; const regularFontFamily = useFontFamily("regular"); const mediumFontFamily = useFontFamily("medium"); const boldFontFamily = useFontFamily("bold"); diff --git a/apps/mobile/src/features/files/FileTreeBrowser.tsx b/apps/mobile/src/features/files/FileTreeBrowser.tsx index f89bea133023..bce58d838a7d 100644 --- a/apps/mobile/src/features/files/FileTreeBrowser.tsx +++ b/apps/mobile/src/features/files/FileTreeBrowser.tsx @@ -7,7 +7,6 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AppText as Text } from "../../components/AppText"; import { PierreEntryIcon } from "../../components/PierreEntryIcon"; import { cn } from "../../lib/cn"; -import { useThemeColor } from "../../lib/useThemeColor"; import { IOS_NAV_BAR_HEIGHT } from "../../lib/layoutMetrics"; import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; import { @@ -46,7 +45,6 @@ const FileTreeRow = memo(function FileTreeRow(props: { readonly item: VisibleFileTreeNode; readonly selected: boolean; readonly expanded: boolean; - readonly iconColor: string; readonly onPressDirectory: (path: string) => void; readonly onPreviewFile?: (path: string) => void; readonly onPressFile: (path: string) => void; @@ -79,7 +77,7 @@ const FileTreeRow = memo(function FileTreeRow(props: { ) : ( @@ -125,7 +123,6 @@ export function FileTreeBrowser(props: { // Native transparent-header height ≈ safe-area top + nav bar (~44). Matches the // observed adjustedContentInset bottom (~102) seen in the native trace. const headerInset = NATIVE_LIQUID_GLASS_SUPPORTED ? insets.top + IOS_NAV_BAR_HEIGHT : 0; - const iconColor = String(useThemeColor("--color-icon-muted")); const { onPreviewFile, onSelectFile, selectedPath: controlledSelectedPath } = props; const controlledSelectedPathRef = useRef(controlledSelectedPath); const pendingSelectionTimeoutRef = useRef | null>(null); @@ -216,13 +213,12 @@ export function FileTreeBrowser(props: { item={item} selected={item.node.kind === "file" && item.node.path === selectedPath} expanded={expandedPaths.has(item.node.path)} - iconColor={iconColor} onPressDirectory={toggleDirectory} onPreviewFile={onPreviewFile} onPressFile={handleSelectFile} /> ), - [expandedPaths, handleSelectFile, iconColor, onPreviewFile, selectedPath, toggleDirectory], + [expandedPaths, handleSelectFile, onPreviewFile, selectedPath, toggleDirectory], ); if (props.error && props.entries.length === 0) { diff --git a/apps/mobile/src/features/files/SourceFileSurface.tsx b/apps/mobile/src/features/files/SourceFileSurface.tsx index 942d0b4ffb95..2eabce998e8e 100644 --- a/apps/mobile/src/features/files/SourceFileSurface.tsx +++ b/apps/mobile/src/features/files/SourceFileSurface.tsx @@ -17,6 +17,7 @@ import { cn } from "../../lib/cn"; import type { ResolvedMobileCodeSurface } from "../../lib/appearancePreferences"; import { useAppearanceCodeSurface } from "../settings/appearance/useAppearanceCodeSurface"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; +import { useUniwindTheme } from "../../lib/useUniwindTheme"; import { buildNativeSourceTokens, NATIVE_SOURCE_CONTENT_WIDTH, @@ -153,6 +154,7 @@ function NativeSourceFileSurface( const { NativeView, onRefresh } = props; const { codeSurface, codeWordBreak, nativeSourceStyle } = useAppearanceCodeSurface(); const { themeAppearance, themeId } = useAppearancePreferences(); + const appTheme = useUniwindTheme(); const { width: viewportWidth } = useWindowDimensions(); const { rowsJson, status, targetIndex, tokens } = useSourceFileModel(props); const [isPullRefreshing, setIsPullRefreshing] = useState(false); @@ -173,8 +175,8 @@ function NativeSourceFileSurface( [targetIndex], ); const themeJson = useMemo( - () => JSON.stringify(createNativeReviewDiffTheme(themeAppearance, themeId)), - [themeAppearance, themeId], + () => JSON.stringify(createNativeReviewDiffTheme(themeAppearance, themeId, appTheme)), + [appTheme, themeAppearance, themeId], ); const styleJson = useMemo(() => JSON.stringify(nativeSourceStyle), [nativeSourceStyle]); const contentWidth = codeWordBreak diff --git a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx index 28356be18524..95177ed3e92a 100644 --- a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx +++ b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx @@ -1,6 +1,7 @@ import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native"; -import { useCallback, useEffect, useRef, useState } from "react"; +import type { MenuAction } from "@react-native-menu/menu"; +import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react"; import { ActivityIndicator, Platform, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg"; @@ -10,16 +11,28 @@ import { type ProjectReadFileResult, ThreadId, } from "@t3tools/contracts"; +import { videoMimeType } from "@t3tools/shared/video"; +import { + isWorkspaceBrowserPreviewPath, + isWorkspaceImagePreviewPath, + mediaMimeTypeFromExtension, +} from "@t3tools/shared/filePreview"; +import { mediaFileReference } from "@t3tools/client-runtime/media-reference"; -import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; +import { AndroidHeaderIconButton, AndroidScreenHeader } from "../../components/AndroidScreenHeader"; import { SymbolView } from "../../components/AppSymbol"; import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; +import { ControlPillMenu } from "../../components/ControlPill"; import { EmptyState } from "../../components/EmptyState"; +import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal"; import { LoadingScreen } from "../../components/LoadingScreen"; import { resolveFileSelectionNavigationAction } from "../../lib/adaptive-navigation"; import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; +import { isPdfFile } from "../../lib/filePreview"; import { tryOpenExternalUrl } from "../../lib/openExternalUrl"; -import { useThemeColor } from "../../lib/useThemeColor"; +import { useUniwindTheme } from "../../lib/useUniwindTheme"; +import type { MediaVideoPreviewSource } from "../../lib/videoPreviewSource"; +import { useMediaActions, type MediaActionsSource } from "../../lib/mediaActions"; import { useThreadSelection } from "../../state/use-thread-selection"; import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree"; import { useEnvironmentQuery } from "../../state/query"; @@ -43,15 +56,15 @@ import { preloadWorkspaceFileContents } from "./preload-workspace-file"; import { SourceFileSurface } from "./SourceFileSurface"; import { ThreadFileNavigatorPane } from "./thread-file-navigator-pane"; import { WorkspaceFileImagePreview } from "./WorkspaceFileImagePreview"; +import { WorkspaceFileVideoPreview } from "./WorkspaceFileVideoPreview"; import { WorkspaceFileWebPreview } from "./WorkspaceFileWebPreview"; import { basename, - isBrowserPreviewFile, - isImagePreviewFile, isMarkdownPreviewFile, isSvgImagePreviewFile, + isVideoPreviewFile, } from "./filePath"; -import { useWorkspaceFileAssetUrl } from "./workspaceFileAssetUrl"; +import { useWorkspaceFileAssetUrlState } from "./workspaceFileAssetUrl"; type FileViewMode = "preview" | "source"; @@ -80,7 +93,10 @@ function normalizeRouteLine(value: string | null): number | null { } function defaultViewMode(path: string | null): FileViewMode { - return path !== null && (isBrowserPreviewFile(path) || isImagePreviewFile(path)) + return path !== null && + (isWorkspaceBrowserPreviewPath(path) || + isWorkspaceImagePreviewPath(path) || + isVideoPreviewFile(path)) ? "preview" : "source"; } @@ -88,6 +104,10 @@ function defaultViewMode(path: string | null): FileViewMode { function FileContent(props: { readonly activeMode: FileViewMode; readonly previewUri: string | null; + readonly previewUnavailable: boolean; + readonly videoSource: MediaVideoPreviewSource | null; + readonly mediaSource?: MediaActionsSource; + readonly resolveVideoUri: () => Promise; readonly fileContents: string | null; readonly fileError: string | null; readonly relativePath: string; @@ -95,9 +115,24 @@ function FileContent(props: { readonly truncated: boolean; readonly onRefresh?: () => Promise | void; }) { + // Reopening a mutable host file must not reuse a poster from an earlier visit. + const thumbnailInstanceId = useId(); const isMarkdown = isMarkdownPreviewFile(props.relativePath); - const isBrowserFile = isBrowserPreviewFile(props.relativePath); - const isImageFile = isImagePreviewFile(props.relativePath); + const isBrowserFile = isWorkspaceBrowserPreviewPath(props.relativePath); + const isImageFile = isWorkspaceImagePreviewPath(props.relativePath); + + if (isVideoPreviewFile(props.relativePath)) { + return ( + + ); + } if (props.activeMode === "preview" && isImageFile) { if (isSvgImagePreviewFile(props.relativePath)) { @@ -107,6 +142,7 @@ function FileContent(props: { ); } @@ -135,11 +171,11 @@ function FileContent(props: { return ( {props.truncated ? ( - - + + Partial file - + Preview limited to the first 1 MB of a truncated file. @@ -210,7 +246,7 @@ function FilesUnavailable() { } function FilesToolbarBottomFade() { - const sheetColor = String(useThemeColor("--color-sheet")); + const sheetColor = String(useUniwindTheme()["--color-sheet"]); if (process.env.EXPO_OS !== "ios") { return null; @@ -245,8 +281,8 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) { const [searchQuery, setSearchQuery] = useState(""); const isAndroid = Platform.OS === "android"; const { themeAppearance: highlightTheme } = useAppearancePreferences(); - const iconColor = String(useThemeColor("--color-icon-muted")); - const sheetSurfaceColor = String(useThemeColor("--color-sheet-solid")); + const theme = useUniwindTheme(); + const sheetSurfaceColor = theme["--color-sheet-solid"]; const { cwd, environmentId, projectName, selectedThread, threadId } = useThreadFilesWorkspace( props.route.params, ); @@ -413,7 +449,12 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) { ]} /> - + (null); const [previewRevision, setPreviewRevision] = useState(0); - const isBrowserFile = relativePath !== null && isBrowserPreviewFile(relativePath); - const isImageFile = relativePath !== null && isImagePreviewFile(relativePath); + const previewKey = JSON.stringify([environmentId, cwd, relativePath, previewRevision]); + const [fullScreenPreview, setFullScreenPreview] = useState(null); + const isVideoFile = relativePath !== null && isVideoPreviewFile(relativePath); + const isBrowserFile = + relativePath !== null && !isVideoFile && isWorkspaceBrowserPreviewPath(relativePath); + const isImageFile = + relativePath !== null && !isVideoFile && isWorkspaceImagePreviewPath(relativePath); const canPreview = - relativePath !== null && (isMarkdownPreviewFile(relativePath) || isBrowserFile || isImageFile); + relativePath !== null && + (isMarkdownPreviewFile(relativePath) || isBrowserFile || isImageFile || isVideoFile); const activeMode = relativePath !== null && modeOverride?.path === relativePath ? modeOverride.mode : defaultViewMode(relativePath); - const resolvedActiveMode = canPreview ? activeMode : "source"; - const assetPreviewPath = isBrowserFile || isImageFile ? relativePath : null; - const assetPreviewUri = useWorkspaceFileAssetUrl({ + const resolvedActiveMode = isVideoFile ? "preview" : canPreview ? activeMode : "source"; + const assetPreviewPath = isBrowserFile || isImageFile || isVideoFile ? relativePath : null; + const assetPreview = useWorkspaceFileAssetUrlState({ cwd, environmentId, relativePath: assetPreviewPath, threadId, }); + const assetPreviewUri = assetPreview._tag === "Success" ? assetPreview.url : null; + const mediaSource = useMemo( + () => + environmentId !== null && + threadId !== null && + relativePath !== null && + assetPreview.resource !== null && + "path" in assetPreview.resource && + typeof assetPreview.resource.path === "string" && + (isImageFile || isVideoFile) + ? { + reference: mediaFileReference(assetPreview.resource.path, cwd), + name: basename(relativePath), + mimeType: + mediaMimeTypeFromExtension(relativePath.slice(relativePath.lastIndexOf("."))) ?? + "application/octet-stream", + environmentId, + threadId, + resource: assetPreview.resource, + } + : undefined, + [assetPreview.resource, cwd, environmentId, isImageFile, isVideoFile, relativePath, threadId], + ); + const mediaActions = useMediaActions(mediaSource); + const videoSource = useMemo( + () => + environmentId !== null && + relativePath !== null && + assetPreview.resource?._tag === "media-file" + ? { + type: "media", + environmentId, + resource: assetPreview.resource, + name: basename(relativePath), + mimeType: videoMimeType({ name: relativePath, mimeType: "" }) ?? "video/mp4", + actionsSource: mediaSource, + } + : null, + [assetPreview.resource, environmentId, relativePath, mediaSource], + ); const previewUri = assetPreviewUri === null || previewRevision === 0 ? assetPreviewUri : `${assetPreviewUri}${assetPreviewUri.includes("?") ? "&" : "?"}revision=${previewRevision}`; const needsFileContents = relativePath !== null && + !isVideoFile && (resolvedActiveMode === "source" || isMarkdownPreviewFile(relativePath)); const fileQuery = useEnvironmentQuery( environmentId !== null && cwd !== null && relativePath !== null && needsFileContents @@ -549,6 +638,133 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { ); useRegisterWorkspaceInspector(fileInspector.supported ? renderWorkspaceInspector : undefined); + const fileMenuActions = useMemo(() => { + if (relativePath === null) return []; + const canToggleMode = canPreview && !isImageFile && !isVideoFile; + return [ + canToggleMode + ? ({ + id: "preview", + title: "Preview", + icon: "eye", + inline: true, + onPress: () => setModeOverride({ path: relativePath, mode: "preview" }), + } as const) + : null, + canToggleMode + ? ({ + id: "source", + title: "Source", + icon: "doc.text", + inline: true, + onPress: () => setModeOverride({ path: relativePath, mode: "source" }), + } as const) + : null, + ...(mediaSource + ? mediaActions.actions + .filter(({ id }) => id !== "open-file") + .map((action) => ({ + id: action.id, + title: action.title, + icon: + action.id === "share" ? ("square.and.arrow.up" as const) : ("doc.on.doc" as const), + inline: false, + onPress: action.run, + })) + : [ + { + id: "copy-path", + title: "Copy path", + icon: "doc.on.doc", + inline: false, + onPress: () => copyTextWithHaptic(relativePath), + } as const, + ]), + isPdfFile({ name: relativePath }) && previewUri !== null + ? ({ + id: "open-pdf", + title: "Open PDF", + icon: "arrow.up.left.and.arrow.down.right", + inline: false, + onPress: () => + setFullScreenPreview({ + kind: "pdf", + uri: previewUri, + name: basename(relativePath), + }), + } as const) + : null, + isBrowserFile && typeof assetPreviewUri === "string" + ? ({ + id: "open-browser", + title: Platform.OS === "ios" ? "Open in Safari" : "Open in browser", + icon: "safari", + inline: false, + onPress: () => tryOpenExternalUrl(assetPreviewUri, "file-preview"), + } as const) + : null, + resolvedActiveMode === "preview" && (isBrowserFile || isImageFile || isVideoFile) + ? ({ + id: "refresh", + title: "Refresh", + icon: "arrow.clockwise", + inline: false, + onPress: async () => { + if (isVideoFile) await assetPreview.refresh(); + setPreviewRevision((current) => current + 1); + }, + } as const) + : null, + ].filter((action) => action !== null); + }, [ + assetPreviewUri, + assetPreview.refresh, + previewUri, + canPreview, + isBrowserFile, + isImageFile, + isVideoFile, + relativePath, + resolvedActiveMode, + mediaSource, + mediaActions.actions, + ]); + + const androidFileMenuActions = useMemo( + () => + fileMenuActions.map((action) => ({ + id: action.id, + title: action.title, + image: action.icon, + state: action.id === resolvedActiveMode ? "on" : undefined, + })), + [fileMenuActions, resolvedActiveMode], + ); + const handleAndroidFileMenuAction = useCallback( + (event: { nativeEvent: { event: string } }) => { + const action = fileMenuActions.find(({ id }) => id === event.nativeEvent.event); + void action?.onPress(); + }, + [fileMenuActions], + ); + const handleReturnToThread = useCallback(() => { + if (environmentId !== null && threadId !== null) { + navigation.dispatch( + StackActions.replace("Thread", { + environmentId: String(environmentId), + threadId: String(threadId), + }), + ); + } + }, [environmentId, navigation, threadId]); + const handleBack = useCallback(() => { + if (navigation.canGoBack()) { + navigation.goBack(); + return; + } + handleReturnToThread(); + }, [handleReturnToThread, navigation]); + if (selectedThread === null || environmentId === null || threadId === null) { return ; } @@ -577,6 +793,7 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { // Static header config lives in Stack.tsx (SOLID_HEADER_OPTIONS: solid // sheet-colored header — this route's content scrolls internally, so // there is nothing for glass to sample). Only dynamic values here. + headerShown: !isAndroid, headerTintColor: iconColor, headerTitle: basename(relativePath), title: basename(relativePath), @@ -584,19 +801,40 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { Platform.OS === "ios" && headerSubtitle.length > 0 ? headerSubtitle : undefined, }} /> + {isAndroid ? ( + + {fileInspector.supported ? ( + + ) : null} + + + + + } + /> + ) : null} {fileInspector.supported ? ( { - navigation.dispatch( - StackActions.replace("Thread", { - environmentId: String(environmentId), - threadId: String(threadId), - }), - ); - }} + onPress={handleReturnToThread} /> ) : null} @@ -612,55 +850,43 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { /> ) : null} - {canPreview && !isImageFile ? ( + {fileMenuActions.some(({ inline }) => inline) ? ( + {fileMenuActions + .filter(({ inline }) => inline) + .map((action) => ( + + {action.title} + + ))} + + ) : null} + {fileMenuActions + .filter(({ inline }) => !inline) + .map((action) => ( setModeOverride({ path: relativePath, mode: "preview" })} - > - Preview - - setModeOverride({ path: relativePath, mode: "source" })} + key={action.id} + icon={action.icon} + onPress={action.onPress} > - Source + {action.title} - - ) : null} - copyTextWithHaptic(relativePath)} - > - Copy path - - {isBrowserFile && typeof assetPreviewUri === "string" ? ( - { - void tryOpenExternalUrl(assetPreviewUri, "file-preview"); - }} - > - Open in Safari - - ) : null} - {resolvedActiveMode === "preview" && (isBrowserFile || isImageFile) ? ( - { - setPreviewRevision((current) => current + 1); - }} - > - Refresh - - ) : null} + ))} fileQuery.refresh()} /> + setFullScreenPreview(null)} + /> ); diff --git a/apps/mobile/src/features/files/WorkspaceFileImagePreview.tsx b/apps/mobile/src/features/files/WorkspaceFileImagePreview.tsx index 73eca66bf999..3e6afae6f84e 100644 --- a/apps/mobile/src/features/files/WorkspaceFileImagePreview.tsx +++ b/apps/mobile/src/features/files/WorkspaceFileImagePreview.tsx @@ -1,24 +1,29 @@ import { useAtomValue } from "@effect/atom-react"; -import { useMemo, useState } from "react"; +import { useId, useMemo, useState } from "react"; import { ActivityIndicator, Image, Pressable, View } from "react-native"; -import ImageViewing from "react-native-image-viewing"; import { AsyncResult } from "effect/unstable/reactivity"; import { AppText as Text } from "../../components/AppText"; import { EmptyState } from "../../components/EmptyState"; import { workspaceFileImageAtom } from "./workspace-file-image-cache"; +import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal"; +import { PresentationSource } from "../../components/NativePresentation"; +import { useMediaActions, type MediaActionsSource } from "../../lib/mediaActions"; +import { MediaActionsMenu } from "../../components/MediaActionsMenu"; function ResolvedWorkspaceFileImagePreview(props: { readonly accessibilityLabel: string; readonly uri: string; + readonly actionsSource?: MediaActionsSource; }) { const [loadError, setLoadError] = useState(null); - const [fullScreenVisible, setFullScreenVisible] = useState(false); + const [preview, setPreview] = useState(null); + const sourceIdentifier = useId(); + const mediaActions = useMediaActions(props.actionsSource); const imageSource = useMemo( () => ({ uri: props.uri, cache: "force-cache" as const }), [props.uri], ); - const fullScreenImages = useMemo(() => [imageSource], [imageSource]); return ( @@ -27,34 +32,40 @@ function ResolvedWorkspaceFileImagePreview(props: { accessibilityLabel={`Open full-screen preview of ${props.accessibilityLabel}`} disabled={loadError !== null} className="flex-1 p-4 active:bg-subtle-strong" - onPress={() => setFullScreenVisible(true)} + onPress={() => + setPreview({ + kind: "image", + uri: props.uri, + name: props.accessibilityLabel, + sourceIdentifier, + actionsSource: props.actionsSource, + }) + } > - setLoadError(null)} - onError={(event) => { - setLoadError(event.nativeEvent.error || "The image could not be rendered."); - }} - /> + + setLoadError(null)} + onError={(event) => { + setLoadError(event.nativeEvent.error || "The image could not be rendered."); + }} + /> + - {loadError !== null ? ( ) : null} - setFullScreenVisible(false)} - swipeToCloseEnabled - doubleTapToZoomEnabled - /> + + + + + setPreview(null)} /> ); } @@ -62,6 +73,7 @@ function ResolvedWorkspaceFileImagePreview(props: { function CachedWorkspaceFileImagePreview(props: { readonly accessibilityLabel: string; readonly uri: string; + readonly actionsSource?: MediaActionsSource; }) { const imageAtom = useMemo(() => workspaceFileImageAtom(props.uri), [props.uri]); const imageResult = useAtomValue(imageAtom); @@ -90,6 +102,7 @@ function CachedWorkspaceFileImagePreview(props: { ); } @@ -97,6 +110,7 @@ function CachedWorkspaceFileImagePreview(props: { export function WorkspaceFileImagePreview(props: { readonly accessibilityLabel: string; readonly uri: string | null; + readonly actionsSource?: MediaActionsSource; }) { if (props.uri === null) { return ( @@ -113,6 +127,7 @@ export function WorkspaceFileImagePreview(props: { ); } diff --git a/apps/mobile/src/features/files/WorkspaceFileVideoPreview.tsx b/apps/mobile/src/features/files/WorkspaceFileVideoPreview.tsx new file mode 100644 index 000000000000..aaa13427fac0 --- /dev/null +++ b/apps/mobile/src/features/files/WorkspaceFileVideoPreview.tsx @@ -0,0 +1,47 @@ +import { useState } from "react"; +import { View } from "react-native"; + +import { EmptyState } from "../../components/EmptyState"; +import { MediaVideoPlayer } from "../../components/MediaVideoPlayer"; +import { VideoPreviewModal, type VideoPreviewSource } from "../../components/VideoPreviewModal"; +import type { MediaVideoPreviewSource } from "../../lib/videoPreviewSource"; + +/** Uses the signed progressive URL directly; choosing a file never preloads its video bytes as text. */ +export function WorkspaceFileVideoPreview(props: { + readonly name: string; + readonly thumbnailKey: string; + readonly uri: string | null; + readonly source: MediaVideoPreviewSource | null; + readonly resolvePlaybackUri: () => Promise; + readonly unavailable: boolean; +}) { + const [preview, setPreview] = useState(null); + const uri = props.uri; + + if (props.unavailable) { + return ( + + + + ); + } + + return ( + + setPreview(props.source) + } + /> + setPreview(null)} /> + + ); +} diff --git a/apps/mobile/src/features/files/filePath.test.ts b/apps/mobile/src/features/files/filePath.test.ts index af0ace61fc01..376068c21157 100644 --- a/apps/mobile/src/features/files/filePath.test.ts +++ b/apps/mobile/src/features/files/filePath.test.ts @@ -1,11 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; -import { - isBrowserPreviewFile, - isImagePreviewFile, - isSvgImagePreviewFile, - resolveWorkspaceRelativeFilePath, -} from "./filePath"; +import { isSvgImagePreviewFile, resolveWorkspaceRelativeFilePath } from "./filePath"; describe("resolveWorkspaceRelativeFilePath", () => { it("keeps normalized workspace-relative paths", () => { @@ -29,13 +24,6 @@ describe("resolveWorkspaceRelativeFilePath", () => { }); describe("file preview types", () => { - it("recognizes browser and image previews", () => { - expect(isBrowserPreviewFile("reports/summary.html")).toBe(true); - expect(isImagePreviewFile("assets/icon.png")).toBe(true); - expect(isImagePreviewFile("assets/diagram.SVG?raw=1")).toBe(true); - expect(isImagePreviewFile("src/image.ts")).toBe(false); - }); - it("identifies SVG images that need web rendering", () => { expect(isSvgImagePreviewFile("assets/diagram.svg#icon")).toBe(true); expect(isSvgImagePreviewFile("assets/photo.png")).toBe(false); diff --git a/apps/mobile/src/features/files/filePath.ts b/apps/mobile/src/features/files/filePath.ts index 385d5c139eea..76672ec5658d 100644 --- a/apps/mobile/src/features/files/filePath.ts +++ b/apps/mobile/src/features/files/filePath.ts @@ -1,7 +1,4 @@ -import { - isWorkspaceBrowserPreviewPath, - isWorkspaceImagePreviewPath, -} from "@t3tools/shared/filePreview"; +import { isWorkspaceVideoPreviewPath } from "@t3tools/shared/filePreview"; export interface FileBreadcrumb { readonly label: string; @@ -87,12 +84,8 @@ export function resolveWorkspaceRelativeFilePath( return normalizeRelativePath(normalizedTarget.slice(normalizedRoot.length + 1)); } -export function isBrowserPreviewFile(path: string): boolean { - return isWorkspaceBrowserPreviewPath(path); -} - -export function isImagePreviewFile(path: string): boolean { - return isWorkspaceImagePreviewPath(path); +export function isVideoPreviewFile(path: string): boolean { + return isWorkspaceVideoPreviewPath(path); } export function isSvgImagePreviewFile(path: string): boolean { diff --git a/apps/mobile/src/features/files/preload-workspace-file.ts b/apps/mobile/src/features/files/preload-workspace-file.ts index b9e21cfd98f9..a91e4f84b0d0 100644 --- a/apps/mobile/src/features/files/preload-workspace-file.ts +++ b/apps/mobile/src/features/files/preload-workspace-file.ts @@ -1,9 +1,13 @@ import { executeAtomQuery } from "@t3tools/client-runtime/state/runtime"; import type { EnvironmentId } from "@t3tools/contracts"; +import { + isWorkspaceBrowserPreviewPath, + isWorkspaceImagePreviewPath, +} from "@t3tools/shared/filePreview"; import { appAtomRegistry } from "../../state/atom-registry"; import { projectEnvironment } from "../../state/projects"; -import { isBrowserPreviewFile, isImagePreviewFile } from "./filePath"; +import { isVideoPreviewFile } from "./filePath"; import { prepareSourceFileDocument } from "./source-file-document"; import { sourceHighlightAtom } from "./sourceHighlightingState"; import type { ReviewDiffTheme } from "../review/shikiReviewHighlighter"; @@ -25,7 +29,11 @@ export function preloadWorkspaceFileContents(input: { readonly relativePath: string; readonly theme: ReviewDiffTheme; }): void { - if (isBrowserPreviewFile(input.relativePath) || isImagePreviewFile(input.relativePath)) { + if ( + isWorkspaceBrowserPreviewPath(input.relativePath) || + isWorkspaceImagePreviewPath(input.relativePath) || + isVideoPreviewFile(input.relativePath) + ) { return; } diff --git a/apps/mobile/src/features/files/thread-file-navigator-pane.tsx b/apps/mobile/src/features/files/thread-file-navigator-pane.tsx index e13f3f61b51b..33b99dd8e8ce 100644 --- a/apps/mobile/src/features/files/thread-file-navigator-pane.tsx +++ b/apps/mobile/src/features/files/thread-file-navigator-pane.tsx @@ -12,7 +12,7 @@ import { import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; import { nativeHeaderScrollEdgeEffects } from "../../native/StackHeader"; -import { useThemeColor } from "../../lib/useThemeColor"; +import { useUniwindTheme } from "../../lib/useUniwindTheme"; import { projectEnvironment } from "../../state/projects"; import { useEnvironmentQuery } from "../../state/query"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; @@ -29,9 +29,9 @@ export function ThreadFileNavigatorPane(props: { }) { const [searchQuery, setSearchQuery] = useState(""); const { themeAppearance: highlightTheme } = useAppearancePreferences(); - const iconColor = String(useThemeColor("--color-icon-muted")); - const foregroundColor = String(useThemeColor("--color-foreground")); - const sheetColor = String(useThemeColor("--color-sheet")); + const theme = useUniwindTheme(); + const foregroundColor = theme["--color-foreground"]; + const sheetColor = theme["--color-sheet"]; const headerScrollEdgeEffects = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version); const entriesQuery = useEnvironmentQuery( projectEnvironment.listEntries({ @@ -152,11 +152,21 @@ export function ThreadFileNavigatorPane(props: { className="h-8 w-8 items-center justify-center rounded-full active:bg-subtle" onPress={entriesQuery.refresh} > - + - + ( + () => + absolutePath !== null && props.threadId !== null + ? { + _tag: isVideoPreviewFile(absolutePath) ? "media-file" : "workspace-file", + threadId: props.threadId, + path: absolutePath, + } + : null, + [absolutePath, props.threadId], ); + const state = useAssetUrlState(props.environmentId, resource); + const refresh = useRefreshAssetUrl(props.environmentId, resource); + return { ...state, resource, refresh }; } diff --git a/apps/mobile/src/features/home/AndroidHomeFab.tsx b/apps/mobile/src/features/home/AndroidHomeFab.tsx index c57964fce4a3..6957a6dab043 100644 --- a/apps/mobile/src/features/home/AndroidHomeFab.tsx +++ b/apps/mobile/src/features/home/AndroidHomeFab.tsx @@ -3,7 +3,6 @@ import { Platform, Pressable, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { SymbolView } from "../../components/AppSymbol"; -import { useThemeColor } from "../../lib/useThemeColor"; /** * Android-only wrapper that overlays a bottom-right new-task FAB on a thread @@ -25,8 +24,6 @@ function AndroidHomeFab(props: { readonly children: ReactNode; }) { const insets = useSafeAreaInsets(); - const primaryForegroundColor = useThemeColor("--color-primary-foreground"); - return ( {props.children} @@ -42,7 +39,7 @@ function AndroidHomeFab(props: { diff --git a/apps/mobile/src/features/home/HomeHeader.tsx b/apps/mobile/src/features/home/HomeHeader.tsx index e7ce41cb43bd..cbeadf59f6f6 100644 --- a/apps/mobile/src/features/home/HomeHeader.tsx +++ b/apps/mobile/src/features/home/HomeHeader.tsx @@ -12,7 +12,7 @@ import { SymbolView } from "../../components/AppSymbol"; import { T3Wordmark } from "../../components/T3Wordmark"; import { HOME_HORIZONTAL_INSET } from "../../lib/layoutMetrics"; import { resolveMobileStageLabel } from "../../lib/mobileBranding"; -import { useThemeColor } from "../../lib/useThemeColor"; +import { useUniwindTheme } from "../../lib/useUniwindTheme"; import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled"; import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items"; @@ -67,8 +67,6 @@ function checkedMenuState(checked: boolean) { function AndroidHomeHeader(props: HomeHeaderProps) { const insets = useSafeAreaInsets(); - const iconColor = useThemeColor("--color-icon"); - const mutedColor = useThemeColor("--color-foreground-muted"); const stageLabel = resolveMobileStageLabel(Constants.expoConfig?.extra?.appVariant); // Thread List v2 lays the list out in fixed creation order, so the // sort/group filter controls would be silently ignored — hide them and @@ -218,7 +216,7 @@ function AndroidHomeHeader(props: HomeHeaderProps) { brand={ {/* Mirrors the desktop SidebarBrand: T3 mark + muted "Code". */} - + Code @@ -248,7 +246,7 @@ function AndroidHomeHeader(props: HomeHeaderProps) { : "line.3.horizontal.decrease.circle" } size={16} - tintColor={iconColor} + tintColorClassName={"accent-icon"} type="monochrome" /> @@ -262,12 +260,22 @@ function AndroidHomeHeader(props: HomeHeaderProps) { onPress={props.onOpenSettings} className="size-11 items-center justify-center rounded-full bg-subtle" > - + - + @@ -300,7 +308,7 @@ function AndroidHomeHeader(props: HomeHeaderProps) { function IosHomeHeader(props: HomeHeaderProps) { const searchBarRef = useRef(null); - const iconColor = useThemeColor("--color-icon"); + const iconColor = useUniwindTheme()["--color-icon"]; // Thread List v2 lays the list out in fixed creation order, so the // sort/group filter controls would be silently ignored — hide them and // key the "customized" icon state off the environment filter alone. diff --git a/apps/mobile/src/features/home/HomeRouteScreen.tsx b/apps/mobile/src/features/home/HomeRouteScreen.tsx index beabf66d9ea9..943303202216 100644 --- a/apps/mobile/src/features/home/HomeRouteScreen.tsx +++ b/apps/mobile/src/features/home/HomeRouteScreen.tsx @@ -2,7 +2,7 @@ import * as Arr from "effect/Array"; import * as Order from "effect/Order"; import { useNavigation } from "@react-navigation/native"; import { useEffect, useMemo, useState } from "react"; -import { Platform } from "react-native"; +import { Platform, useWindowDimensions } from "react-native"; import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; import { useProjects, useThreadShells } from "../../state/entities"; @@ -17,6 +17,7 @@ import { AndroidHomeFabLayout } from "./AndroidHomeFab"; import { HomeScreen } from "./HomeScreen"; import { HomeHeader } from "./HomeHeader"; import { useHomeListOptions } from "./home-list-options"; +import { useHomeThreadSelection } from "./home-thread-navigation"; import { buildHomeProjectScopes } from "./homeThreadList"; import { usePendingTaskListActions } from "./usePendingTaskListActions"; import { useThreadListActions } from "./useThreadListActions"; @@ -25,6 +26,7 @@ import { getConnectionAwareBrandHeaderOptions } from "./WorkspaceConnectionTitle /* ─── Route screen ───────────────────────────────────────────────────── */ export function HomeRouteScreen() { + const { width: windowWidth } = useWindowDimensions(); const { layout } = useAdaptiveWorkspaceLayout(); const projects = useProjects(); const threads = useThreadShells(); @@ -32,6 +34,7 @@ export function HomeRouteScreen() { const { savedConnectionsById } = useSavedRemoteConnections(); const navigation = useNavigation(); const [searchQuery, setSearchQuery] = useState(""); + const handleSelectThread = useHomeThreadSelection(); useEffect(() => { void checkForAppUpdateOnLaunch(); @@ -138,8 +141,10 @@ export function HomeRouteScreen() { shallow-merged. The brand slot also doubles as the connection status surface while an environment reconnects. */} navigation.navigate("SettingsSheet", { screen: "SettingsContent", @@ -206,14 +211,7 @@ export function HomeRouteScreen() { } onProjectSortOrderChange={setProjectSortOrder} onSearchQueryChange={setSearchQuery} - onSelectThread={(thread) => { - // Settled threads are live shells: opening one is plain - // navigation, and sending a message un-settles server-side. - navigation.navigate("Thread", { - environmentId: thread.environmentId, - threadId: thread.id, - }); - }} + onSelectThread={handleSelectThread} onSelectPendingTask={openPendingTask} onDeletePendingTask={confirmDeletePendingTask} onNewThreadInProject={(project) => { diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 0026876696d6..34f4f4057a5d 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -12,7 +12,6 @@ import { type EnvironmentThreadSearchMatch, } from "@t3tools/client-runtime/state/thread-search"; import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort"; -import type { ChangeRequestSettleSource } from "@t3tools/client-runtime/state/thread-settled"; import type { EnvironmentId, SidebarProjectGroupingMode, @@ -20,11 +19,11 @@ import type { } from "@t3tools/contracts"; import { useAtomSet, useAtomValue } from "@effect/atom-react"; import { AsyncResult } from "effect/unstable/reactivity"; +import { useFocusEffect } from "@react-navigation/native"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { ActivityIndicator, FlatList, Platform, Pressable, View } from "react-native"; import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { useThemeColor } from "../../lib/useThemeColor"; import { AppText as Text } from "../../components/AppText"; import { EmptyState } from "../../components/EmptyState"; @@ -209,14 +208,10 @@ export function HomeScreen(props: HomeScreenProps) { >(() => new Map()); const preferencesResult = useAtomValue(mobilePreferencesAtom); const threadListV2Enabled = useThreadListV2Enabled(); - const autoSettleOnMerge = - !AsyncResult.isSuccess(preferencesResult) || - preferencesResult.value.autoSettleOnMerge !== false; const savePreferences = useAtomSet(updateMobilePreferencesAtom); const openSwipeableRef = useRef(null); const listRef = useRef(null); const insets = useSafeAreaInsets(); - const accentColor = useThemeColor("--color-icon-muted"); const iosBottomToolbarClearance = Platform.OS === "ios" && !NATIVE_LIQUID_GLASS_SUPPORTED ? PRE_LIQUID_GLASS_BOTTOM_TOOLBAR_HEIGHT @@ -488,32 +483,6 @@ export function HomeScreen(props: HomeScreenProps) { // Settled threads stay in the live shell stream (settled ≠ archived), so // the partition works directly off live shells — no snapshot merging or // optimistic holds. - // PR states stream in per-row. The next partition applies the configured - // merge rule and the always-on close rule, matching web. - const [changeRequestByKey, setChangeRequestByKey] = useState< - ReadonlyMap - >(() => new Map()); - const handleChangeRequestState = useCallback( - (threadKey: string, changeRequest: ChangeRequestSettleSource | null) => { - setChangeRequestByKey((current) => { - const existing = current.get(threadKey) ?? null; - if ( - (existing?.state ?? null) === (changeRequest?.state ?? null) && - (existing?.updatedAt ?? null) === (changeRequest?.updatedAt ?? null) - ) { - return current; - } - const next = new Map(current); - if (changeRequest === null) { - next.delete(threadKey); - } else { - next.set(threadKey, changeRequest); - } - return next; - }); - }, - [], - ); const handleSettleThread = useCallback( (thread: EnvironmentThreadShell) => { void props.onSettleThread(thread); @@ -580,23 +549,21 @@ export function HomeScreen(props: HomeScreenProps) { toggleSettledShelf, toggleSnoozedShelf, } = useThreadListV2ShelfPreferences(); - // now is quantized to the minute and ticks so the inactivity auto-settle - // boundary is actually crossed while the app stays open (mirrors web); - // without a clock dependency the partition memoizes a frozen "now". + // The queued-start and snooze helpers need a clock while the list stays open. const [nowMinute, setNowMinute] = useState(() => new Date().toISOString().slice(0, 16)); // Snooze wake times are second-precise; a counter bumped exactly at the // next wake boundary re-runs the partition with a fresh clock so a woken // thread reappears immediately instead of on the next minute tick. const [snoozeWakeTick, bumpSnoozeWakeTick] = useState(0); - useEffect(() => { - if (!threadListV2Enabled) return; - // Refresh immediately on enable: the mount-time value can be hours old - // by the time the beta is switched on, which would misclassify the - // inactivity auto-settle boundary until the first tick. - setNowMinute(new Date().toISOString().slice(0, 16)); - const id = setInterval(() => setNowMinute(new Date().toISOString().slice(0, 16)), 60_000); - return () => clearInterval(id); - }, [threadListV2Enabled]); + useFocusEffect( + useCallback(() => { + if (!threadListV2Enabled) return; + // Refresh immediately on enable or focus because the previous value can be hours old. + setNowMinute(new Date().toISOString().slice(0, 16)); + const id = setInterval(() => setNowMinute(new Date().toISOString().slice(0, 16)), 60_000); + return () => clearInterval(id); + }, [threadListV2Enabled]), + ); // Threads on servers without the settlement capability never classify as // settled (the user could neither un-settle nor pin them). const serverConfigs = useAtomValue(environmentServerConfigsAtom); @@ -678,20 +645,15 @@ export function HomeScreen(props: HomeScreenProps) { projectRefs: v2ScopedProjectGroup === null ? null : v2ScopedProjectGroup.projectRefs, searchQuery: props.searchQuery, matchedThreadKeys, - changeRequestByKey, - autoSettleOnMerge, settlementEnvironmentIds, snoozeEnvironmentIds, settledLimit: settledVisibleCount, - now: `${nowMinute}:00.000Z`, - snoozeNow: new Date().toISOString(), + now: new Date().toISOString(), snoozedShelfExpanded, settledShelfExpanded, selectedThreadKey: null, }); }, [ - changeRequestByKey, - autoSettleOnMerge, nowMinute, snoozeWakeTick, snoozedShelfExpanded, @@ -863,7 +825,6 @@ export function HomeScreen(props: HomeScreenProps) { onPinThread={handlePinThread} onUnpinThread={handleUnpinThread} onMovePinnedThread={handleMovePinnedThread} - onChangeRequestState={handleChangeRequestState} projectCwd={ projectCwdByKey.get(scopedProjectKey(thread.environmentId, thread.projectId)) ?? null } @@ -873,7 +834,6 @@ export function HomeScreen(props: HomeScreenProps) { ); }, [ - handleChangeRequestState, handleDeleteThread, arrangedPinnedKeys, handleMovePinnedThread, @@ -1087,7 +1047,7 @@ export function HomeScreen(props: HomeScreenProps) { /> {emptyState.loading ? ( - + ) : null} diff --git a/apps/mobile/src/features/home/WorkspaceConnectionTitle.tsx b/apps/mobile/src/features/home/WorkspaceConnectionTitle.tsx index 1867042988ba..9b9333b46c7f 100644 --- a/apps/mobile/src/features/home/WorkspaceConnectionTitle.tsx +++ b/apps/mobile/src/features/home/WorkspaceConnectionTitle.tsx @@ -1,15 +1,14 @@ -import type { - NativeStackHeaderItem, - NativeStackNavigationOptions, -} from "@react-navigation/native-stack"; +import type { NativeStackNavigationOptions } from "@react-navigation/native-stack"; import { useEffect, useRef, useState, type ReactNode } from "react"; -import { ActivityIndicator, Animated, Platform, Pressable, View } from "react-native"; +import { ActivityIndicator, Animated, Pressable, View } from "react-native"; import { SymbolView } from "../../components/AppSymbol"; import { AppText as Text } from "../../components/AppText"; -import { brandTitleOffset, CompactBrandTitle } from "../../components/CompactBrandTitle"; -import { useThemeColor } from "../../lib/useThemeColor"; -import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; +import { + brandTitleOffset, + CompactBrandTitle, + getCompactBrandHeaderOptions, +} from "../../components/CompactBrandTitle"; import { useWorkspaceState } from "../../state/workspace"; import { workspaceConnectionStatusPresentation, @@ -52,7 +51,11 @@ function useDelayedConnectionStatus(): WorkspaceConnectionStatusPresentation | n * native-driver animated nodes blank the re-hosted view entirely. The JS driver * updates opacity through the ordinary style path, which those subviews handle. */ -function StatusFadeIn(props: { readonly children: ReactNode; readonly grow?: boolean }) { +function StatusFadeIn(props: { + readonly children: ReactNode; + readonly grow?: boolean; + readonly maxWidth?: number; +}) { const opacity = useRef(new Animated.Value(0)).current; useEffect(() => { @@ -68,7 +71,7 @@ function StatusFadeIn(props: { readonly children: ReactNode; readonly grow?: boo return ( @@ -97,8 +100,9 @@ export function WorkspaceConnectionTitle(props: { readonly size?: "navbar" | "pageTitle"; /** Horizontal correction so the status aligns with the brand in native title slots. */ readonly statusOffset?: number; + /** Space available beside the native header actions. */ + readonly maxWidth?: number; }) { - const iconColor = String(useThemeColor("--color-icon-muted")); const status = useDelayedConnectionStatus(); const size = props.size ?? "navbar"; @@ -113,7 +117,7 @@ export function WorkspaceConnectionTitle(props: { } return ( - + {status.showsProgress ? ( - + ) : ( )} @@ -156,39 +160,24 @@ export function WorkspaceConnectionTitle(props: { * this over the static brand options at mount. */ export function getConnectionAwareBrandHeaderOptions(opts: { + readonly headerWidth: number; + readonly trailingItemCount?: number; readonly onOpenEnvironments: () => void; readonly fallbackTitleStyle?: NativeStackNavigationOptions["headerTitleStyle"]; }): NativeStackNavigationOptions { - if (Platform.OS === "ios" && NATIVE_LIQUID_GLASS_SUPPORTED) { - return { - headerTitle: "Threads", - headerTitleStyle: { color: "transparent", fontSize: 18, fontWeight: "800" }, - title: "Threads", - unstable_headerLeftItems: (): NativeStackHeaderItem[] => [ - { - element: ( - } - onPress={opts.onOpenEnvironments} - statusOffset={brandTitleOffset(true)} - /> - ), - hidesSharedBackground: true, - type: "custom", - }, - ], - }; - } + // Leave room for bar margins, title spacing and the 44-point native actions. + // Long status labels must not push Settings into UIKit's overflow menu. + const maxWidth = Math.max(0, opts.headerWidth - 64 - 44 * (opts.trailingItemCount ?? 1)); return { + ...getCompactBrandHeaderOptions(opts.fallbackTitleStyle), headerTitle: () => ( } + maxWidth={maxWidth} onPress={opts.onOpenEnvironments} - statusOffset={brandTitleOffset(false)} + statusOffset={brandTitleOffset()} /> ), - headerTitleStyle: opts.fallbackTitleStyle, - title: "Threads", }; } diff --git a/apps/mobile/src/features/home/home-thread-navigation.test.ts b/apps/mobile/src/features/home/home-thread-navigation.test.ts new file mode 100644 index 000000000000..30ef1e7a711b --- /dev/null +++ b/apps/mobile/src/features/home/home-thread-navigation.test.ts @@ -0,0 +1,196 @@ +import * as NodeModule from "node:module"; +import type { + StackNavigationState, + StackRouter as StackRouterType, + StackActions as StackActionsType, +} from "@react-navigation/native"; +import { describe, expect, it, vi } from "vite-plus/test"; + +function loadRouters() { + const require = NodeModule.createRequire(import.meta.url); + const nativePackage = require.resolve("@react-navigation/native/package.json"); + const requireFromNative = NodeModule.createRequire(nativePackage); + const corePackage = requireFromNative.resolve("@react-navigation/core/package.json"); + const requireFromCore = NodeModule.createRequire(corePackage); + return requireFromCore("@react-navigation/routers") as { + readonly CommonActions: typeof import("@react-navigation/native").CommonActions; + readonly StackActions: typeof StackActionsType; + readonly StackRouter: typeof StackRouterType; + }; +} + +vi.mock("@react-navigation/native", () => { + const { CommonActions, StackActions } = loadRouters(); + return { CommonActions, StackActions }; +}); + +import { createHomeThreadNavigationAction } from "./home-thread-navigation"; + +const { StackActions, StackRouter } = loadRouters(); +const routeNames = ["Home", "Thread"]; +const routeParamList = { + Home: undefined, + Thread: undefined, +}; +const router = StackRouter({}); +const routerOptions = { + routeNames, + routeParamList, + routeGetIdList: {}, +}; + +type ThreadSelection = Parameters[0]["thread"]; + +function thread(id: string): ThreadSelection { + return { + environmentId: "environment-1", + id, + } as ThreadSelection; +} + +function initialState() { + return router.getInitialState(routerOptions); +} + +function apply( + state: StackNavigationState>, + action: Parameters[1], +) { + const nextState = router.getStateForAction(state, action, routerOptions); + expect(nextState).not.toBeNull(); + return nextState as StackNavigationState>; +} + +function selectThread( + state: ReturnType, + selectedThread: ThreadSelection, + dismissingRouteKey: string | null = null, +) { + return apply( + state, + createHomeThreadNavigationAction({ + state, + dismissingRouteKey, + thread: selectedThread, + }), + ); +} + +function dismissRoute(state: ReturnType, routeKey: string) { + return apply(state, { + ...StackActions.pop(), + source: routeKey, + target: state.key, + }); +} + +describe("createHomeThreadNavigationAction", () => { + it("coalesces ordinary repeat selections onto the current thread route", () => { + const firstState = selectThread(initialState(), thread("thread-a")); + const threadRouteKey = firstState.routes[firstState.index]?.key; + const secondAction = createHomeThreadNavigationAction({ + state: firstState, + dismissingRouteKey: null, + thread: thread("thread-b"), + }); + + const secondState = apply(firstState, secondAction); + expect(secondState.routes).toHaveLength(2); + expect(secondState.routes[secondState.index]).toMatchObject({ + key: threadRouteKey, + name: "Thread", + params: { environmentId: "environment-1", threadId: "thread-b" }, + }); + }); + + it("keeps an overlap selection after native dismisses the outgoing route", () => { + const outgoingState = selectThread(initialState(), thread("thread-a")); + const outgoingRouteKey = outgoingState.routes[outgoingState.index]?.key; + expect(outgoingRouteKey).toBeDefined(); + + const overlapAction = createHomeThreadNavigationAction({ + state: outgoingState, + dismissingRouteKey: outgoingRouteKey ?? null, + thread: thread("thread-b"), + }); + const overlapState = apply(outgoingState, overlapAction); + const incomingRoute = overlapState.routes[overlapState.index]; + expect(incomingRoute?.key).not.toBe(outgoingRouteKey); + + const dismissedState = dismissRoute(overlapState, outgoingRouteKey!); + expect(dismissedState.routes).toHaveLength(2); + expect(dismissedState.routes[dismissedState.index]).toMatchObject({ + key: incomingRoute?.key, + name: "Thread", + params: { environmentId: "environment-1", threadId: "thread-b" }, + }); + }); + + it("uses a fresh key for the same thread selected during dismissal", () => { + const outgoingState = selectThread(initialState(), thread("thread-a")); + const outgoingRouteKey = outgoingState.routes[outgoingState.index]?.key; + expect(outgoingRouteKey).toBeDefined(); + + const overlapState = selectThread(outgoingState, thread("thread-a"), outgoingRouteKey ?? null); + const incomingRouteKey = overlapState.routes[overlapState.index]?.key; + expect(incomingRouteKey).not.toBe(outgoingRouteKey); + + const dismissedState = dismissRoute(overlapState, outgoingRouteKey!); + expect(dismissedState.routes[dismissedState.index]).toMatchObject({ + key: incomingRouteKey, + params: { environmentId: "environment-1", threadId: "thread-a" }, + }); + }); + + it("coalesces a second overlap selection onto the fresh incoming route", () => { + const outgoingState = selectThread(initialState(), thread("thread-a")); + const outgoingRouteKey = outgoingState.routes[outgoingState.index]?.key; + expect(outgoingRouteKey).toBeDefined(); + + const firstOverlapState = selectThread( + outgoingState, + thread("thread-b"), + outgoingRouteKey ?? null, + ); + const incomingRouteKey = firstOverlapState.routes[firstOverlapState.index]?.key; + const secondOverlapAction = createHomeThreadNavigationAction({ + state: firstOverlapState, + dismissingRouteKey: outgoingRouteKey ?? null, + thread: thread("thread-c"), + }); + + const secondOverlapState = apply(firstOverlapState, secondOverlapAction); + expect(secondOverlapState.routes).toHaveLength(3); + expect(secondOverlapState.routes[secondOverlapState.index]).toMatchObject({ + key: incomingRouteKey, + params: { environmentId: "environment-1", threadId: "thread-c" }, + }); + + const dismissedState = dismissRoute(secondOverlapState, outgoingRouteKey!); + expect(dismissedState.routes).toHaveLength(2); + expect(dismissedState.routes[dismissedState.index]).toMatchObject({ + key: incomingRouteKey, + params: { environmentId: "environment-1", threadId: "thread-c" }, + }); + }); + + it("uses ordinary navigation when native pops before the selection", () => { + const outgoingState = selectThread(initialState(), thread("thread-a")); + const outgoingRouteKey = outgoingState.routes[outgoingState.index]?.key; + expect(outgoingRouteKey).toBeDefined(); + + const poppedState = dismissRoute(outgoingState, outgoingRouteKey!); + const action = createHomeThreadNavigationAction({ + state: poppedState, + dismissingRouteKey: outgoingRouteKey ?? null, + thread: thread("thread-b"), + }); + + const selectedState = apply(poppedState, action); + expect(selectedState.routes).toHaveLength(2); + expect(selectedState.routes[selectedState.index]).toMatchObject({ + name: "Thread", + params: { environmentId: "environment-1", threadId: "thread-b" }, + }); + }); +}); diff --git a/apps/mobile/src/features/home/home-thread-navigation.ts b/apps/mobile/src/features/home/home-thread-navigation.ts new file mode 100644 index 000000000000..f26e624ac3a8 --- /dev/null +++ b/apps/mobile/src/features/home/home-thread-navigation.ts @@ -0,0 +1,71 @@ +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; +import { + CommonActions, + StackActions, + useNavigation, + type NavigationState, +} from "@react-navigation/native"; +import type { NativeStackNavigationProp } from "@react-navigation/native-stack"; +import { useCallback, useEffect, useRef } from "react"; + +type ThreadSelection = Pick; + +export function createHomeThreadNavigationAction(input: { + readonly state: Pick; + readonly dismissingRouteKey: string | null; + readonly thread: ThreadSelection; +}) { + const currentRoute = input.state.routes[input.state.index]; + const params = { + environmentId: input.thread.environmentId, + threadId: input.thread.id, + }; + + // Native swipe-back pops the outgoing route after its animation. Reusing + // that key would also discard this selection when the dismissal arrives. + if (input.dismissingRouteKey !== null && currentRoute?.key === input.dismissingRouteKey) { + return StackActions.push("Thread", params); + } + + return CommonActions.navigate("Thread", params); +} + +export function useHomeThreadSelection() { + const navigation = + useNavigation>(); + const dismissingRouteKey = useRef(null); + + useEffect(() => { + const clear = () => { + dismissingRouteKey.current = null; + }; + // This listener belongs to Home, so swipe-back is its opening transition. + // Thread's closing event is targeted at the outgoing Thread route. + const removeTransitionStart = navigation.addListener("transitionStart", ({ data }) => { + const state = navigation.getState(); + const currentRoute = state.routes[state.index]; + dismissingRouteKey.current = + !data.closing && currentRoute?.name === "Thread" ? currentRoute.key : null; + }); + const removeFocus = navigation.addListener("focus", clear); + + return () => { + clear(); + removeTransitionStart(); + removeFocus(); + }; + }, [navigation]); + + return useCallback( + (thread: ThreadSelection) => { + navigation.dispatch((state) => + createHomeThreadNavigationAction({ + state, + dismissingRouteKey: dismissingRouteKey.current, + thread, + }), + ); + }, + [navigation], + ); +} diff --git a/apps/mobile/src/features/home/thread-swipe-actions.tsx b/apps/mobile/src/features/home/thread-swipe-actions.tsx index 973c4fae9ce3..052ac969c10f 100644 --- a/apps/mobile/src/features/home/thread-swipe-actions.tsx +++ b/apps/mobile/src/features/home/thread-swipe-actions.tsx @@ -370,34 +370,45 @@ function SwipeActionButton(props: { readonly stretchesOnFullSwipe: boolean; readonly translation: SharedValue; }) { + const { + actionsWidth, + entryRange: [entryRangeStart, entryRangeEnd], + fullSwipeThreshold, + stretchesOnFullSwipe, + translation, + } = props; const circleSize = props.compact ? COMPACT_ACTION_CIRCLE_SIZE : ACTION_CIRCLE_SIZE; const iconSize = props.compact ? COMPACT_ACTION_ICON_SIZE : ACTION_ICON_SIZE; const actionStyle = useAnimatedStyle(() => { - const reveal = Math.max(-props.translation.value, 0); - const entryProgress = interpolate(reveal, props.entryRange, [0, 1], Extrapolation.CLAMP); - const stretch = Math.max(reveal - props.actionsWidth, 0); + const reveal = Math.max(-translation.value, 0); + const entryProgress = interpolate( + reveal, + [entryRangeStart, entryRangeEnd], + [0, 1], + Extrapolation.CLAMP, + ); + const stretch = Math.max(reveal - actionsWidth, 0); const fullSwipeProgress = interpolate( reveal, - [props.actionsWidth, props.fullSwipeThreshold + 20], + [actionsWidth, fullSwipeThreshold + 20], [0, 1], Extrapolation.CLAMP, ); return { - opacity: props.stretchesOnFullSwipe ? entryProgress : entryProgress * (1 - fullSwipeProgress), + opacity: stretchesOnFullSwipe ? entryProgress : entryProgress * (1 - fullSwipeProgress), transform: [ { translateX: - interpolate(entryProgress, [0, 1], [22, 0]) - - (props.stretchesOnFullSwipe ? 0 : stretch), + interpolate(entryProgress, [0, 1], [22, 0]) - (stretchesOnFullSwipe ? 0 : stretch), }, { scale: interpolate(entryProgress, [0, 1], [0.78, 1]) }, ], }; }); const circleStyle = useAnimatedStyle(() => { - const reveal = Math.max(-props.translation.value, 0); - const stretch = props.stretchesOnFullSwipe ? Math.max(reveal - props.actionsWidth, 0) : 0; + const reveal = Math.max(-translation.value, 0); + const stretch = stretchesOnFullSwipe ? Math.max(reveal - actionsWidth, 0) : 0; return { transform: [{ translateX: -stretch }], @@ -405,11 +416,11 @@ function SwipeActionButton(props: { }; }); const iconStyle = useAnimatedStyle(() => { - const reveal = Math.max(-props.translation.value, 0); - const stretch = props.stretchesOnFullSwipe ? Math.max(reveal - props.actionsWidth, 0) : 0; + const reveal = Math.max(-translation.value, 0); + const stretch = stretchesOnFullSwipe ? Math.max(reveal - actionsWidth, 0) : 0; const armedProgress = interpolate( reveal, - [props.fullSwipeThreshold, props.fullSwipeThreshold + 20], + [fullSwipeThreshold, fullSwipeThreshold + 20], [0, 1], Extrapolation.CLAMP, ); @@ -419,16 +430,16 @@ function SwipeActionButton(props: { }; }); const labelStyle = useAnimatedStyle(() => { - if (!props.stretchesOnFullSwipe) { + if (!stretchesOnFullSwipe) { return { opacity: 1 }; } - const reveal = Math.max(-props.translation.value, 0); - const stretch = Math.max(reveal - props.actionsWidth, 0); + const reveal = Math.max(-translation.value, 0); + const stretch = Math.max(reveal - actionsWidth, 0); return { opacity: interpolate( reveal, - [props.fullSwipeThreshold - 24, props.fullSwipeThreshold], + [fullSwipeThreshold - 24, fullSwipeThreshold], [1, 0], Extrapolation.CLAMP, ), @@ -532,17 +543,17 @@ export function ThreadSwipeActions(props: { readonly secondaryAction: ThreadSwipeSecondaryAction | null; readonly translation: SharedValue; }) { - const secondaryAction = props.secondaryAction; + const { fullSwipeThreshold, onFullSwipeArmedChange, secondaryAction, translation } = props; const fullSwipeIsPrimary = props.fullSwipeAction === "primary" || secondaryAction === null; const actionsWidth = swipeActionsWidth(secondaryAction !== null); useAnimatedReaction( - () => -props.translation.value >= props.fullSwipeThreshold, + () => -translation.value >= fullSwipeThreshold, (armed, previous) => { if (armed !== previous) { - runOnJS(props.onFullSwipeArmedChange)(armed); + runOnJS(onFullSwipeArmedChange)(armed); } }, - [props.fullSwipeThreshold, props.onFullSwipeArmedChange], + [fullSwipeThreshold, onFullSwipeArmedChange, translation], ); return ( diff --git a/apps/mobile/src/features/home/usePendingTaskListActions.ts b/apps/mobile/src/features/home/usePendingTaskListActions.ts index 3f0867ba0e2c..403c3af391de 100644 --- a/apps/mobile/src/features/home/usePendingTaskListActions.ts +++ b/apps/mobile/src/features/home/usePendingTaskListActions.ts @@ -2,7 +2,7 @@ import { useNavigation } from "@react-navigation/native"; import { useCallback } from "react"; import { Alert } from "react-native"; -import { removeThreadOutboxMessage } from "../../state/thread-outbox"; +import { removeThreadOutboxMessage } from "../../state/thread-outbox-removal"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; import { releaseEditingQueuedMessage } from "../../state/use-thread-outbox"; diff --git a/apps/mobile/src/features/home/useThreadListActions.ts b/apps/mobile/src/features/home/useThreadListActions.ts index 5c66944042ad..dae6c46a89dd 100644 --- a/apps/mobile/src/features/home/useThreadListActions.ts +++ b/apps/mobile/src/features/home/useThreadListActions.ts @@ -1,5 +1,5 @@ import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; -import { canSettle, canSnooze } from "@t3tools/client-runtime/state/thread-settled"; +import { canSnooze } from "@t3tools/client-runtime/state/thread-settled"; import * as Cause from "effect/Cause"; import * as Haptics from "expo-haptics"; import { useCallback, useRef } from "react"; @@ -118,16 +118,6 @@ function useThreadActionExecutor( ); return false; } - // Settle may only target what effectiveSettled could classify as - // settled: not starting/running sessions, not threads waiting on - // approvals or user input. Anything else would hide live work. - if (action === "settle" && !canSettle(thread, { now: new Date().toISOString() })) { - Alert.alert( - actionFailureTitle(action), - "This thread still needs attention. Resolve or interrupt it first, then try again.", - ); - return false; - } // Archive keeps its original, narrower guard: never interrupt a // thread mid-turn. if ( diff --git a/apps/mobile/src/features/keyboard/HardwareKeyboardCommandProvider.tsx b/apps/mobile/src/features/keyboard/HardwareKeyboardCommandProvider.tsx index 96a4a63e9018..909bbcf5a762 100644 --- a/apps/mobile/src/features/keyboard/HardwareKeyboardCommandProvider.tsx +++ b/apps/mobile/src/features/keyboard/HardwareKeyboardCommandProvider.tsx @@ -1,7 +1,22 @@ import { StackActions, useNavigation } from "@react-navigation/native"; -import { useCallback, useMemo, useSyncExternalStore, type PropsWithChildren } from "react"; +import { resolveThreadReferenceCopyTarget } from "@t3tools/shared/threadReference"; +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + useSyncExternalStore, + type PropsWithChildren, +} from "react"; +import { tryCopyTextWithHaptic } from "../../lib/copyTextWithHaptic"; import { T3KeyboardCommands } from "../../native/T3KeyboardCommands"; +import { useProject, useThreadShell } from "../../state/entities"; +import { useEnvironmentQuery } from "../../state/query"; +import type { GitActionProgress } from "../../state/use-vcs-action-state"; +import { vcsEnvironment } from "../../state/vcs"; +import { GitActionProgressOverlay } from "../threads/GitActionProgressOverlay"; import { dispatchHardwareKeyboardCommand, getHardwareKeyboardCommandRegistrationVersion, @@ -11,11 +26,86 @@ import { type HardwareKeyboardCommand, } from "./hardwareKeyboardCommands"; +const EMPTY_COPY_FEEDBACK: GitActionProgress = { + phase: "idle", + label: null, + description: null, +}; +const COPY_FEEDBACK_DISMISS_MS = 3_000; + export function HardwareKeyboardCommandProvider({ children, pathname, }: PropsWithChildren<{ readonly pathname: string }>) { const navigation = useNavigation(); + const activeThreadRef = useMemo(() => parseActiveThreadPath(pathname), [pathname]); + const activeThread = useThreadShell(activeThreadRef); + const activeProjectRef = useMemo( + () => + activeThread === null + ? null + : { + environmentId: activeThread.environmentId, + projectId: activeThread.projectId, + }, + [activeThread], + ); + const activeProject = useProject(activeProjectRef); + const activeThreadCwd = activeThread?.worktreePath ?? activeProject?.workspaceRoot ?? null; + const gitStatus = useEnvironmentQuery( + activeThread !== null && + activeThread.linkedPullRequest == null && + activeThread.branch !== null && + activeThreadCwd !== null + ? vcsEnvironment.status({ + environmentId: activeThread.environmentId, + input: { cwd: activeThreadCwd }, + }) + : null, + ).data; + const detectedPullRequestUrl = + activeThread?.branch != null && gitStatus?.refName === activeThread.branch + ? (gitStatus.pr?.url ?? null) + : null; + const copyTarget = useMemo( + () => + activeThreadRef === null + ? null + : resolveThreadReferenceCopyTarget({ + threadId: activeThread?.id ?? activeThreadRef.threadId, + linkedPullRequestUrl: activeThread?.linkedPullRequest?.url ?? null, + detectedPullRequestUrl, + }), + [activeThread, activeThreadRef, detectedPullRequestUrl], + ); + const [copyFeedback, setCopyFeedback] = useState(EMPTY_COPY_FEEDBACK); + const copyRequestIdRef = useRef(0); + const copyFeedbackTimerRef = useRef | null>(null); + const dismissCopyFeedback = useCallback(() => { + if (copyFeedbackTimerRef.current !== null) { + clearTimeout(copyFeedbackTimerRef.current); + copyFeedbackTimerRef.current = null; + } + setCopyFeedback(EMPTY_COPY_FEEDBACK); + }, []); + const showCopyFeedback = useCallback((feedback: GitActionProgress) => { + if (copyFeedbackTimerRef.current !== null) { + clearTimeout(copyFeedbackTimerRef.current); + } + setCopyFeedback(feedback); + copyFeedbackTimerRef.current = setTimeout(() => { + copyFeedbackTimerRef.current = null; + setCopyFeedback(EMPTY_COPY_FEEDBACK); + }, COPY_FEEDBACK_DISMISS_MS); + }, []); + useEffect( + () => () => { + if (copyFeedbackTimerRef.current !== null) { + clearTimeout(copyFeedbackTimerRef.current); + } + }, + [], + ); const registrationVersion = useSyncExternalStore( subscribeToHardwareKeyboardCommandRegistrations, getHardwareKeyboardCommandRegistrationVersion, @@ -25,10 +115,11 @@ export function HardwareKeyboardCommandProvider({ const commands = new Set(getRegisteredHardwareKeyboardCommands()); commands.add("newTask"); if (pathname !== "/" || navigation.canGoBack()) commands.add("back"); - if (parseActiveThreadPath(pathname)) { + if (activeThreadRef !== null) { commands.add("files"); commands.add("terminal"); commands.add("review"); + if (pathname.split("/")[4] !== "terminal") commands.add("copyThreadReference"); } return [...commands]; }, [pathname, registrationVersion, navigation]); @@ -37,6 +128,30 @@ export function HardwareKeyboardCommandProvider({ (command: HardwareKeyboardCommand) => { if (dispatchHardwareKeyboardCommand(command)) return; + if (command === "copyThreadReference") { + if (copyTarget === null) return; + const requestId = ++copyRequestIdRef.current; + void tryCopyTextWithHaptic(copyTarget.value, { + target: copyTarget.clipboardTarget, + }).then((didCopy) => { + if (requestId !== copyRequestIdRef.current) return; + showCopyFeedback( + didCopy + ? { + phase: "success", + label: copyTarget.successTitle, + description: copyTarget.value, + } + : { + phase: "error", + label: copyTarget.failureTitle, + description: "Try again.", + }, + ); + }); + return; + } + if (command === "newTask") { navigation.navigate("NewTaskSheet", { screen: "NewTask" }); return; @@ -62,12 +177,15 @@ export function HardwareKeyboardCommandProvider({ navigation.navigate("ThreadReview", thread); } }, - [pathname, navigation], + [copyTarget, navigation, pathname, showCopyFeedback], ); return ( - - {children} - + <> + + {children} + + + ); } diff --git a/apps/mobile/src/features/keyboard/hardwareKeyboardCommands.ts b/apps/mobile/src/features/keyboard/hardwareKeyboardCommands.ts index 300434eb736a..fa1c953849f9 100644 --- a/apps/mobile/src/features/keyboard/hardwareKeyboardCommands.ts +++ b/apps/mobile/src/features/keyboard/hardwareKeyboardCommands.ts @@ -8,6 +8,7 @@ export type HardwareKeyboardCommand = | "files" | "terminal" | "review" + | "copyThreadReference" | "toggleSidebar"; type CommandHandler = () => boolean | void; diff --git a/apps/mobile/src/features/layout/WorkspaceEmptyDetail.tsx b/apps/mobile/src/features/layout/WorkspaceEmptyDetail.tsx index 66ce2a0aaf58..6982b5cb4c31 100644 --- a/apps/mobile/src/features/layout/WorkspaceEmptyDetail.tsx +++ b/apps/mobile/src/features/layout/WorkspaceEmptyDetail.tsx @@ -2,15 +2,17 @@ import { SymbolView } from "../../components/AppSymbol"; import { Pressable, View } from "react-native"; import { AppText as Text } from "../../components/AppText"; -import { useThemeColor } from "../../lib/useThemeColor"; export function WorkspaceEmptyDetail(props: { readonly onStartNewTask?: () => void }) { - const iconColor = useThemeColor("--color-icon-subtle"); - return ( - + Select a thread Choose a thread from the sidebar or start a new task. diff --git a/apps/mobile/src/features/layout/workspace-pane-divider.tsx b/apps/mobile/src/features/layout/workspace-pane-divider.tsx index d476452efa58..63966282266f 100644 --- a/apps/mobile/src/features/layout/workspace-pane-divider.tsx +++ b/apps/mobile/src/features/layout/workspace-pane-divider.tsx @@ -2,7 +2,7 @@ import { useCallback, useMemo, useRef, useState } from "react"; import { Pressable, StyleSheet, View, type AccessibilityActionEvent } from "react-native"; import { Gesture, GestureDetector } from "react-native-gesture-handler"; import { runOnJS } from "react-native-reanimated"; -import { useThemeColor } from "../../lib/useThemeColor"; +import { cn } from "../../lib/cn"; const ACCESSIBILITY_RESIZE_STEP = 24; @@ -22,8 +22,6 @@ export function WorkspacePaneDivider(props: WorkspacePaneDividerProps) { latestProps.current = props; const [hovered, setHovered] = useState(false); const [dragging, setDragging] = useState(false); - const dividerColor = useThemeColor("--color-border"); - const activeDividerColor = useThemeColor("--color-primary"); const handleResizeStart = useCallback(() => { setDragging(true); latestProps.current.onResizeStart?.(); @@ -81,11 +79,11 @@ export function WorkspacePaneDivider(props: WorkspacePaneDividerProps) { onHoverOut={() => setHovered(false)} > @@ -96,11 +94,9 @@ const styles = StyleSheet.create({ line: { alignSelf: "center", height: "100%", - opacity: 0.7, width: StyleSheet.hairlineWidth, }, activeLine: { - opacity: 1, width: 2, }, }); diff --git a/apps/mobile/src/features/projects/AddProjectScreen.tsx b/apps/mobile/src/features/projects/AddProjectScreen.tsx index b48c7a0bdd94..a82f6937378e 100644 --- a/apps/mobile/src/features/projects/AddProjectScreen.tsx +++ b/apps/mobile/src/features/projects/AddProjectScreen.tsx @@ -51,7 +51,6 @@ import { sourceControlEnvironment } from "../../state/sourceControl"; import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; import { ErrorBanner } from "../../components/ErrorBanner"; import { SourceControlIcon } from "../../components/SourceControlIcon"; -import { useThemeColor } from "../../lib/useThemeColor"; import { uuidv4 } from "../../lib/uuid"; import { useAtomCommand } from "../../state/use-atom-command"; import { useAtomQueryRunner } from "../../state/use-atom-query-runner"; @@ -159,8 +158,6 @@ function ListRow(props: { readonly right?: ReactNode; readonly onPress?: () => void; }) { - const chevronColor = useThemeColor("--color-chevron"); - return ( + ) : null} @@ -205,8 +207,6 @@ function PrimaryActionButton(props: { readonly loading?: boolean; readonly onPress: () => void; }) { - const primaryForeground = useThemeColor("--color-primary-foreground"); - return ( {props.loading ? ( - + ) : ( {props.label} )} @@ -414,7 +414,6 @@ function SourceControlRow(props: { readonly isFirst: boolean; }) { const navigation = useNavigation(); - const iconColor = useThemeColor("--color-icon"); const title = props.source === "url" ? "Git URL" : `${addProjectRemoteSourceLabel(props.source)} repository`; const subtitle = @@ -423,9 +422,9 @@ function SourceControlRow(props: { : `Clone ${addProjectRemoteSourceLabel(props.source)} ${props.hint}`; const icon = props.source === "url" ? ( - + ) : ( - + ); if (!props.ready) { @@ -454,8 +453,6 @@ function SourceControlRow(props: { export function AddProjectSourceScreen() { const navigation = useNavigation(); - const accentColor = useThemeColor("--color-icon-muted"); - const iconColor = useThemeColor("--color-icon"); const { environmentOptions, selectedEnvironment, setSelectedEnvironmentId } = useSelectedEnvironment(); const discoveryState = useEnvironmentQuery( @@ -496,7 +493,7 @@ export function AddProjectSourceScreen() { } @@ -508,7 +505,7 @@ export function AddProjectSourceScreen() { ) : null @@ -530,7 +527,7 @@ export function AddProjectSourceScreen() { } @@ -560,7 +557,9 @@ export function AddProjectSourceScreen() { ), )} - {discoveryState.isPending ? : null} + {discoveryState.isPending ? ( + + ) : null} ) : null} @@ -745,7 +744,6 @@ function FolderBrowser(props: { }) => Promise; readonly pinnedDirectoryName?: string; }) { - const accentColor = useThemeColor("--color-icon-muted"); const browsePath = useMemo( () => getFilesystemBrowsePath(props.pathInput, props.environment.platform), [props.environment.platform, props.pathInput], @@ -781,7 +779,7 @@ function FolderBrowser(props: { {browseState.isPending && browseState.data === null ? ( - + ) : null} {browsePath.canBrowseUp ? ( @@ -791,7 +789,7 @@ function FolderBrowser(props: { } @@ -810,7 +808,14 @@ function FolderBrowser(props: { } + icon={ + + } isFirst={index === 0 && !browsePath.canBrowseUp} right={null} onPress={() => { diff --git a/apps/mobile/src/features/review/ReviewCommentComposerSheet.tsx b/apps/mobile/src/features/review/ReviewCommentComposerSheet.tsx index 40f8fcf153bb..74ccc8cf0bcb 100644 --- a/apps/mobile/src/features/review/ReviewCommentComposerSheet.tsx +++ b/apps/mobile/src/features/review/ReviewCommentComposerSheet.tsx @@ -5,7 +5,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { Platform, Pressable, ScrollView, View, useWindowDimensions } from "react-native"; import { KeyboardAvoidingView, KeyboardStickyView } from "react-native-keyboard-controller"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import ImageViewing from "react-native-image-viewing"; +import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal"; import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; import { SymbolView } from "../../components/AppSymbol"; @@ -14,7 +14,6 @@ import { ControlPill } from "../../components/ControlPill"; import { cn } from "../../lib/cn"; import type { DraftComposerImageAttachment } from "../../lib/composerImages"; import { convertPastedImagesToAttachments, pickComposerImages } from "../../lib/composerImages"; -import { useThemeColor } from "../../lib/useThemeColor"; import { useNativePaste } from "../../lib/useNativePaste"; import { setPendingConnectionError } from "../../state/use-remote-environment-registry"; import { appendReviewCommentToDraft } from "../../state/use-thread-composer-state"; @@ -46,7 +45,6 @@ export function ReviewCommentComposerSheet(props: ReviewCommentComposerSheetProp const insets = useSafeAreaInsets(); const { width } = useWindowDimensions(); const { themeAppearance: selectedTheme } = useAppearancePreferences(); - const iconTint = String(useThemeColor("--color-icon")); const target = useReviewCommentTarget(); const { codeSurface } = useAppearanceCodeSurface(); const { environmentId, threadId } = props.route.params; @@ -55,7 +53,7 @@ export function ReviewCommentComposerSheet(props: ReviewCommentComposerSheetProp Record> >({}); const [attachments, setAttachments] = useState>([]); - const [previewImageUri, setPreviewImageUri] = useState(null); + const [previewFile, setPreviewFile] = useState(null); const selectedLines = useMemo( () => (target ? getSelectedReviewCommentLines(target) : []), @@ -168,7 +166,12 @@ export function ReviewCommentComposerSheet(props: ReviewCommentComposerSheetProp className="bg-subtle h-12 w-12 items-center justify-center rounded-full" onPress={dismissComposer} > - + Add Comment @@ -269,7 +272,7 @@ export function ReviewCommentComposerSheet(props: ReviewCommentComposerSheetProp attachments={attachments} imageBorderRadius={16} imageSize={60} - onPressImage={setPreviewImageUri} + onPressPreview={setPreviewFile} removeButtonPlacement="gutter" onRemove={(imageId) => { setAttachments((current) => @@ -329,14 +332,7 @@ export function ReviewCommentComposerSheet(props: ReviewCommentComposerSheetProp ) : null} - setPreviewImageUri(null)} - swipeToCloseEnabled - doubleTapToZoomEnabled - /> + setPreviewFile(null)} /> ); } diff --git a/apps/mobile/src/features/review/ReviewHighlighterProvider.tsx b/apps/mobile/src/features/review/ReviewHighlighterProvider.tsx index 150584aedeaa..e35367b74719 100644 --- a/apps/mobile/src/features/review/ReviewHighlighterProvider.tsx +++ b/apps/mobile/src/features/review/ReviewHighlighterProvider.tsx @@ -1,4 +1,4 @@ -import { createContext, type ReactNode, useContext, useMemo } from "react"; +import { createContext, type ReactNode, useMemo } from "react"; import { type ReviewHighlighterState, useReviewHighlighterState } from "./reviewHighlighterState"; @@ -18,7 +18,3 @@ export function ReviewHighlighterProvider(props: { readonly children: ReactNode ); } - -export function useReviewHighlighterStatus(): ReviewHighlighterState { - return useContext(ReviewHighlighterContext); -} diff --git a/apps/mobile/src/features/review/ReviewSheet.tsx b/apps/mobile/src/features/review/ReviewSheet.tsx index 0524371738fb..80ebe1157d92 100644 --- a/apps/mobile/src/features/review/ReviewSheet.tsx +++ b/apps/mobile/src/features/review/ReviewSheet.tsx @@ -37,7 +37,7 @@ import { ControlPillMenu } from "../../components/ControlPill"; import { environmentCatalog } from "../../connection/catalog"; import { useEnvironmentPresentation } from "../../state/presentation"; import { useAtomCommand } from "../../state/use-atom-command"; -import { useThemeColor } from "../../lib/useThemeColor"; +import { useUniwindTheme } from "../../lib/useUniwindTheme"; import { IOS_NAV_BAR_HEIGHT } from "../../lib/layoutMetrics"; import { useThreadDraftForThread } from "../../state/use-thread-composer-state"; import { EnvironmentConnectionNotice } from "../connection/EnvironmentConnectionNotice"; @@ -80,13 +80,11 @@ const SHOWCASE_ENABLED = process.env.EXPO_PUBLIC_SHOWCASE === "1"; const ReviewNotice = memo(function ReviewNotice(props: { readonly notice: string }) { return ( - - + + Partial diff - - {props.notice} - + {props.notice} ); }); @@ -97,7 +95,6 @@ function ReviewSelectionActionBar(props: { readonly onOpenComment: (() => void) | null; readonly onClear: () => void; }) { - const foreground = useThemeColor("--color-primary-foreground"); if (!props.title) { return null; } @@ -107,7 +104,7 @@ function ReviewSelectionActionBar(props: { {props.title} @@ -144,7 +141,12 @@ function ReviewSelectionActionBar(props: { className="h-12 w-12 items-center justify-center rounded-full bg-primary" onPress={props.onClear} > - + ); @@ -217,8 +219,9 @@ function ReviewFileNavigator({ ref, }: ReviewFileNavigatorProps) { const insets = useSafeAreaInsets(); - const sheetColor = String(useThemeColor("--color-sheet")); - const foregroundColor = String(useThemeColor("--color-foreground")); + const theme = useUniwindTheme(); + const sheetColor = theme["--color-sheet"]; + const foregroundColor = theme["--color-foreground"]; const headerScrollEdgeEffects = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version); const [fileSelection, setFileSelection] = useState<{ readonly sectionId: string | null; @@ -348,7 +351,7 @@ export function ReviewSheet(props: ReviewSheetProps) { const navigation = useNavigation(); const insets = useSafeAreaInsets(); const { themeAppearance: selectedTheme } = useAppearancePreferences(); - const headerIcon = String(useThemeColor("--color-icon")); + const headerIcon = String(useUniwindTheme()["--color-icon"]); const { environmentId, threadId } = props.route.params; const environment = useEnvironmentPresentation(environmentId); const retryEnvironment = useAtomCommand(environmentCatalog.retryNow, "environment retry"); diff --git a/apps/mobile/src/features/review/diffParser.ts b/apps/mobile/src/features/review/diffParser.ts deleted file mode 100644 index 76e8872f8ab7..000000000000 --- a/apps/mobile/src/features/review/diffParser.ts +++ /dev/null @@ -1,158 +0,0 @@ -export type ParsedDiffLineType = "context" | "add" | "delete" | "meta" | "hunk"; - -export interface ParsedDiffLine { - readonly id: string; - readonly type: ParsedDiffLineType; - readonly oldLine: number | null; - readonly newLine: number | null; - readonly content: string; -} - -export interface ParsedDiffFile { - readonly id: string; - readonly oldPath: string | null; - readonly newPath: string | null; - readonly lines: ReadonlyArray; -} - -function parseHunkStart( - line: string, -): { readonly oldLine: number; readonly newLine: number } | null { - const match = line.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/); - if (!match) { - return null; - } - - return { - oldLine: Number.parseInt(match[1] ?? "0", 10), - newLine: Number.parseInt(match[2] ?? "0", 10), - }; -} - -function parseDiffPath(line: string, prefix: "--- " | "+++ "): string | null { - if (!line.startsWith(prefix)) { - return null; - } - const raw = line.slice(prefix.length).trim(); - if (raw === "/dev/null") { - return null; - } - return raw.replace(/^[ab]\//, ""); -} - -export function parseUnifiedDiff(diff: string): ReadonlyArray { - const files: ParsedDiffFile[] = []; - let current: { - oldPath: string | null; - newPath: string | null; - lines: ParsedDiffLine[]; - } | null = null; - let oldLine: number | null = null; - let newLine: number | null = null; - - const pushCurrent = () => { - if (!current) { - return; - } - files.push({ - id: `${current.oldPath ?? "null"}:${current.newPath ?? "null"}:${files.length}`, - oldPath: current.oldPath, - newPath: current.newPath, - lines: current.lines, - }); - }; - - for (const rawLine of diff.replace(/\r\n/g, "\n").split("\n")) { - if (rawLine.startsWith("diff --git ")) { - pushCurrent(); - const match = rawLine.match(/^diff --git a\/(.+) b\/(.+)$/); - current = { - oldPath: match?.[1] ?? null, - newPath: match?.[2] ?? null, - lines: [], - }; - oldLine = null; - newLine = null; - continue; - } - - if (!current) { - if (rawLine.trim().length === 0) { - continue; - } - current = { oldPath: null, newPath: null, lines: [] }; - } - - const oldPath = parseDiffPath(rawLine, "--- "); - if (oldPath !== null || rawLine === "--- /dev/null") { - current.oldPath = oldPath; - continue; - } - - const newPath = parseDiffPath(rawLine, "+++ "); - if (newPath !== null || rawLine === "+++ /dev/null") { - current.newPath = newPath; - continue; - } - - const hunk = parseHunkStart(rawLine); - if (hunk) { - oldLine = hunk.oldLine; - newLine = hunk.newLine; - current.lines.push({ - id: `${current.lines.length}:hunk`, - type: "hunk", - oldLine: null, - newLine: null, - content: rawLine, - }); - continue; - } - - if (oldLine === null || newLine === null) { - current.lines.push({ - id: `${current.lines.length}:meta`, - type: "meta", - oldLine: null, - newLine: null, - content: rawLine, - }); - continue; - } - - const marker = rawLine[0]; - const content = rawLine.length > 0 ? rawLine.slice(1) : ""; - if (marker === "+") { - current.lines.push({ - id: `${current.lines.length}:add:${newLine}`, - type: "add", - oldLine: null, - newLine, - content, - }); - newLine += 1; - } else if (marker === "-") { - current.lines.push({ - id: `${current.lines.length}:delete:${oldLine}`, - type: "delete", - oldLine, - newLine: null, - content, - }); - oldLine += 1; - } else { - current.lines.push({ - id: `${current.lines.length}:context:${oldLine}:${newLine}`, - type: "context", - oldLine, - newLine, - content: marker === " " ? content : rawLine, - }); - oldLine += 1; - newLine += 1; - } - } - - pushCurrent(); - return files.filter((file) => file.lines.length > 0 || file.oldPath || file.newPath); -} diff --git a/apps/mobile/src/features/review/nativeReviewDiffAdapter.test.ts b/apps/mobile/src/features/review/nativeReviewDiffAdapter.test.ts index dbd1d7aeb0b9..3a291b2e788a 100644 --- a/apps/mobile/src/features/review/nativeReviewDiffAdapter.test.ts +++ b/apps/mobile/src/features/review/nativeReviewDiffAdapter.test.ts @@ -1,5 +1,12 @@ import { describe, expect, it } from "vite-plus/test"; -import { MOBILE_THEME_IDS } from "../../lib/mobileTheme"; +import { + DEFAULT_MOBILE_THEME_ID, + getMobileThemeVariables, + MOBILE_THEME_IDS, + type MobileThemeAppearance, + type MobileThemeId, +} from "../../lib/mobileTheme"; +import { readDefaultMobileThemeVariables } from "../../lib/mobileTheme.test-support"; import { createNativeReviewDiffTheme, @@ -39,6 +46,12 @@ function buildInput(comments: BuildNativeReviewDiffDataInput["comments"]) { return { parsedDiff, comments } satisfies BuildNativeReviewDiffDataInput; } +function appTheme(themeId: MobileThemeId, appearance: MobileThemeAppearance) { + return themeId === DEFAULT_MOBILE_THEME_ID + ? readDefaultMobileThemeVariables(appearance) + : getMobileThemeVariables(themeId, appearance); +} + describe("getCachedNativeReviewDiffData", () => { it("reuses the row model for equivalent empty comment arrays", () => { const first = getCachedNativeReviewDiffData(buildInput([])); @@ -61,7 +74,11 @@ describe("createNativeReviewDiffTheme", () => { it("serializes every native color as cross-platform opaque hex", () => { for (const themeId of MOBILE_THEME_IDS) { for (const appearance of ["light", "dark"] as const) { - const theme = createNativeReviewDiffTheme(appearance, themeId); + const theme = createNativeReviewDiffTheme( + appearance, + themeId, + appTheme(themeId, appearance), + ); for (const color of Object.values(theme)) { expect(color, `${themeId}/${appearance}`).toMatch(/^#[\da-f]{6}$/i); } @@ -70,8 +87,8 @@ describe("createNativeReviewDiffTheme", () => { }); it("uses the selected app palette for native code surfaces", () => { - const standard = createNativeReviewDiffTheme("dark", "t3-code"); - const iris = createNativeReviewDiffTheme("dark", "iris"); + const standard = createNativeReviewDiffTheme("dark", "t3-code", appTheme("t3-code", "dark")); + const iris = createNativeReviewDiffTheme("dark", "iris", appTheme("iris", "dark")); expect(iris.background).not.toBe(standard.background); expect(iris.hunkText).not.toBe(standard.hunkText); diff --git a/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts b/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts index 66beae22e9fc..a45a955d331f 100644 --- a/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts +++ b/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts @@ -8,11 +8,7 @@ import { pipe } from "effect/Function"; import type { ResolvedMobileCodeSurface } from "../../lib/appearancePreferences"; import { resolveMobileCodeSurface } from "../../lib/appearancePreferences"; import { MOBILE_CODE_SURFACE } from "../../lib/typography"; -import { - DEFAULT_MOBILE_THEME_ID, - getMobileThemeVariables, - type MobileThemeId, -} from "../../lib/mobileTheme"; +import { type MobileThemeId, type MobileThemeVariables } from "../../lib/mobileTheme"; import { getMobileTerminalTheme, type TerminalAppearanceScheme } from "../terminal/terminalTheme"; import { computeWordAltDiffRanges } from "./reviewWordDiffs"; import { @@ -137,10 +133,10 @@ function buildReviewCommentsCacheKey(comments: ReadonlyArray | undefined) => ReadonlyArray | undefined, -): void { - const atom = reviewRevealedLargeFileIdsByThreadKeyAtom(threadKey); - const current = appAtomRegistry.get(atom); - const nextValue = update(current[sectionId]); - appAtomRegistry.set(atom, { - ...current, - [sectionId]: nextValue, - }); -} - export function updateReviewViewedFileIds( threadKey: string, sectionId: string, diff --git a/apps/mobile/src/features/review/shikiReviewHighlighter.ts b/apps/mobile/src/features/review/shikiReviewHighlighter.ts index 008a07619490..c684a6686430 100644 --- a/apps/mobile/src/features/review/shikiReviewHighlighter.ts +++ b/apps/mobile/src/features/review/shikiReviewHighlighter.ts @@ -814,22 +814,6 @@ function storeResolvedHighlightedFile(cacheKey: string, highlighted: ReviewHighl } } -export function clearReviewHighlightFileCache(): void { - highlightCache.clear(); - resolvedHighlightCache.clear(); -} - -export function getCachedHighlightedReviewFile( - file: ReviewRenderableFile, - theme: ReviewDiffTheme, -): ReviewHighlightedFile | null { - if (REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE) { - return null; - } - - return resolvedHighlightCache.get(getHighlightCacheKey(file, theme)) ?? null; -} - export async function highlightReviewFile( file: ReviewRenderableFile, theme: ReviewDiffTheme, diff --git a/apps/mobile/src/features/review/useNativeReviewDiffBridge.ts b/apps/mobile/src/features/review/useNativeReviewDiffBridge.ts index f5effb9485d1..c6a656e012f7 100644 --- a/apps/mobile/src/features/review/useNativeReviewDiffBridge.ts +++ b/apps/mobile/src/features/review/useNativeReviewDiffBridge.ts @@ -6,6 +6,7 @@ import { useAppearanceCodeSurface } from "../settings/appearance/useAppearanceCo import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { useNativeReviewDiffHighlighting } from "./useNativeReviewDiffHighlighting"; import { buildNativeReviewTokensResetKey } from "./reviewDiffBridgeKeys"; +import { useUniwindTheme } from "../../lib/useUniwindTheme"; export { buildNativeReviewTokensResetKey, hashReviewDiffKey } from "./reviewDiffBridgeKeys"; @@ -31,11 +32,15 @@ export function useNativeReviewDiffBridge(input: { } = input; const { nativeReviewDiffStyle } = useAppearanceCodeSurface(); const { themeAppearance: scheme, themeId } = useAppearancePreferences(); + const appTheme = useUniwindTheme(); const [collapsedCommentIds, setCollapsedCommentIds] = useState>( () => new Set(), ); - const theme = useMemo(() => createNativeReviewDiffTheme(scheme, themeId), [scheme, themeId]); + const theme = useMemo( + () => createNativeReviewDiffTheme(scheme, themeId, appTheme), + [appTheme, scheme, themeId], + ); const rowsJson = useMemo(() => JSON.stringify(data.rows), [data.rows]); const collapsedFileIdsJson = useMemo(() => JSON.stringify(collapsedFileIds), [collapsedFileIds]); const viewedFileIdsJson = useMemo(() => JSON.stringify(viewedFileIds), [viewedFileIds]); diff --git a/apps/mobile/src/features/settings/SettingsClientStorageRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsClientStorageRouteScreen.tsx index 9e18d4675fbc..3480340f4093 100644 --- a/apps/mobile/src/features/settings/SettingsClientStorageRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsClientStorageRouteScreen.tsx @@ -1,12 +1,11 @@ import { useAtomSet, useAtomValue } from "@effect/atom-react"; import { AsyncResult } from "effect/unstable/reactivity"; -import { SymbolView } from "expo-symbols"; import { useMemo } from "react"; import { ActivityIndicator, Alert, Pressable, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AppText as Text } from "../../components/AppText"; -import { useThemeColor } from "../../lib/useThemeColor"; +import { SymbolView } from "../../components/AppSymbol"; import { clearClientCacheAtom, clientCacheSummaryAtom, @@ -17,8 +16,6 @@ import { SettingsSection } from "./components/SettingsSection"; export function SettingsClientStorageRouteScreen() { const insets = useSafeAreaInsets(); - const iconColor = useThemeColor("--color-icon"); - const dangerForegroundColor = useThemeColor("--color-danger-foreground"); const summaryResult = useAtomValue(clientCacheSummaryAtom); const clearResult = useAtomValue(clearClientCacheAtom); const clearCache = useAtomSet(clearClientCacheAtom); @@ -84,7 +81,7 @@ export function SettingsClientStorageRouteScreen() { @@ -119,7 +116,7 @@ export function SettingsClientStorageRouteScreen() { @@ -142,14 +139,16 @@ export function SettingsClientStorageRouteScreen() { {summary ? `Clear ${formatBytes(summary.payloadBytes)}` : "Clear caches"} - {isClearing ? : null} + {isClearing ? ( + + ) : null} @@ -174,7 +173,6 @@ function CacheEnvironmentRow(props: { readonly first: boolean; readonly onClear: () => void; }) { - const iconColor = useThemeColor("--color-icon"); return ( diff --git a/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx index 6b6d589fa4f3..793d26511553 100644 --- a/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx @@ -12,7 +12,7 @@ import { CloudEnvironmentRows } from "../connection/CloudEnvironmentRows"; import { ConnectionEnvironmentRow } from "../connection/ConnectionEnvironmentRow"; import { splitEnvironmentSections } from "../connection/environmentSections"; import { cn } from "../../lib/cn"; -import { useThemeColor } from "../../lib/useThemeColor"; +import { useUniwindTheme } from "../../lib/useUniwindTheme"; import { useRemoteConnections } from "../../state/use-remote-environment-registry"; import { applyShowcaseLocalEnvironmentDisplayUrls, @@ -44,8 +44,7 @@ export function SettingsEnvironmentsRouteScreen() { : environmentSections.connectedCloudEnvironments; const hasLocalEnvironments = localEnvironments.length > 0; const [expandedId, setExpandedId] = useState(null); - const accentColor = useThemeColor("--color-icon-muted"); - const headerIconColor = useThemeColor("--color-icon"); + const headerIconColor = useUniwindTheme()["--color-icon"]; const handleToggle = useCallback((environmentId: EnvironmentId) => { setExpandedId((prev) => (prev === environmentId ? null : environmentId)); @@ -148,7 +147,7 @@ export function SettingsEnvironmentsRouteScreen() { diff --git a/apps/mobile/src/features/settings/SettingsProjectGroupingRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsProjectGroupingRouteScreen.tsx index a594240167c6..951168fefcf6 100644 --- a/apps/mobile/src/features/settings/SettingsProjectGroupingRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsProjectGroupingRouteScreen.tsx @@ -8,7 +8,6 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; import { AppText as Text } from "../../components/AppText"; import { SymbolView } from "../../components/AppSymbol"; -import { useThemeColor } from "../../lib/useThemeColor"; import { NativeStackScreenOptions } from "../../native/StackHeader"; import { mobileProjectGroupingModePatch, @@ -42,7 +41,6 @@ const GROUPING_OPTIONS: ReadonlyArray<{ export function SettingsProjectGroupingRouteScreen() { const navigation = useNavigation(); const insets = useSafeAreaInsets(); - const checkmarkColor = useThemeColor("--color-icon"); const preferencesResult = useAtomValue(mobilePreferencesAtom); const savePreferences = useAtomSet(updateMobilePreferencesAtom); const preferencesReady = AsyncResult.isSuccess(preferencesResult) && !preferencesResult.waiting; @@ -92,7 +90,7 @@ export function SettingsProjectGroupingRouteScreen() { diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index b0e851b59d88..81f5d4e986cf 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -33,8 +33,10 @@ import { hasCloudPublicConfig, resolveRelayClerkTokenOptions } from "../cloud/pu import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items"; import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar"; import { runtime } from "../../lib/runtime"; -import { useThemeColor } from "../../lib/useThemeColor"; import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; +import { serverEnvironment } from "../../state/server"; +import { useAtomCommand } from "../../state/use-atom-command"; +import type { EnvironmentId } from "@t3tools/contracts"; import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled"; import { type AppUpdateCheckState, @@ -528,26 +530,54 @@ function ConfiguredSettingsRouteScreen() { } function GeneralSettingsSection() { - const preferencesResult = useAtomValue(mobilePreferencesAtom); - const savePreferences = useAtomSet(updateMobilePreferencesAtom); - const autoSettleOnMerge = - !AsyncResult.isSuccess(preferencesResult) || - preferencesResult.value.autoSettleOnMerge !== false; + const { savedConnectionsById } = useSavedRemoteConnections(); + const connections = Object.values(savedConnectionsById).sort((left, right) => + left.environmentLabel.localeCompare(right.environmentLabel), + ); return ( - savePreferences({ autoSettleOnMerge: value })} - /> + {connections.map((connection) => ( + + ))} ); } +function EnvironmentAutoSettleSwitch(props: { + readonly environmentId: EnvironmentId; + readonly environmentLabel: string; +}) { + const settings = useAtomValue(serverEnvironment.settingsValueAtom(props.environmentId)); + const config = useAtomValue(serverEnvironment.configValueAtom(props.environmentId)); + const updateSettings = useAtomCommand(serverEnvironment.updateSettings, { + label: "auto-settle settings update", + reportFailure: true, + }); + if (config?.environment.capabilities.threadAutoSettlement !== true || settings === null) { + return null; + } + return ( + { + void updateSettings({ + environmentId: props.environmentId, + input: { patch: { sidebarAutoSettleOnMerge: value } }, + }); + }} + /> + ); +} + /** * Device-local legacy toggles. Mobile has no client-settings sync, so this is * the counterpart of web's Settings → General → Legacy features backed by @@ -585,7 +615,6 @@ function LegacySettingsSection() { } function AppSettingsSection() { - const icon = useThemeColor("--color-icon"); const [updateState, setUpdateState] = useState("idle"); const updateInFlight = useRef(false); const hiddenUpdateTapCount = useRef(0); @@ -655,7 +684,7 @@ function AppSettingsSection() { diff --git a/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx b/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx index 96a01c051126..79d67ebaa7c2 100644 --- a/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx +++ b/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx @@ -1,15 +1,23 @@ -import { createContext, use, useCallback, useLayoutEffect, useMemo, type ReactNode } from "react"; -import { useColorScheme } from "react-native"; +import { + createContext, + startTransition, + use, + useCallback, + useLayoutEffect, + useMemo, + useRef, + type ReactNode, +} from "react"; +import { Appearance, useColorScheme } from "react-native"; import { useAtomSet, useAtomValue } from "@effect/atom-react"; import { AsyncResult } from "effect/unstable/reactivity"; -import { Uniwind } from "uniwind"; +import { ScopedTheme, Uniwind } from "uniwind"; import { resolveAppearance, resolveAppearancePreferences, - resolveTextScaleVariables, type ResolvedAppearance, } from "../../../lib/appearancePreferences"; import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../../state/preferences"; @@ -17,7 +25,6 @@ import type { Preferences } from "../../../persistence/mobile-preferences"; import { createMobileThemePairPatch, createMobileThemeSelectionPatch, - getMobileThemeVariables, normalizeMobileThemeMode, resolveMobileThemeIds, type MobileThemeAppearance, @@ -25,6 +32,11 @@ import { type MobileThemeIds, type MobileThemeMode, } from "../../../lib/mobileTheme"; +import { + createMobileThemeRuntimeOperations, + getMobileUniwindThemeName, + type MobileThemeRuntimeState, +} from "../../../lib/mobileThemeRuntime"; import { cacheTerminalFontSize } from "../../terminal/terminalUiState"; interface AppearancePreferencesContextValue { @@ -51,30 +63,6 @@ interface AppearancePreferencesContextValue { const AppearancePreferencesContext = createContext(null); -/** - * Injects palette and text-scale variables into both adaptive stylesheets. - * Updating the active sheet last lets the visible app settle in one pass. - */ -function applyAppearanceVariables(baseFontSize: number, themeIds: MobileThemeIds) { - const textVariables = resolveTextScaleVariables(baseFontSize); - const currentTheme = Uniwind.currentTheme; - const activeAppearance = - currentTheme === "light" || currentTheme === "dark" ? currentTheme : null; - - for (const theme of ["light", "dark"] as const) { - const variables = { ...getMobileThemeVariables(themeIds[theme], theme), ...textVariables }; - if (theme !== activeAppearance) { - Uniwind.updateCSSVariables(theme, variables); - } - } - if (activeAppearance !== null) { - Uniwind.updateCSSVariables(activeAppearance, { - ...getMobileThemeVariables(themeIds[activeAppearance], activeAppearance), - ...textVariables, - }); - } -} - export function AppearancePreferencesProvider(props: { readonly children: ReactNode }) { const preferencesResult = useAtomValue(mobilePreferencesAtom); const savePreferences = useAtomSet(updateMobilePreferencesAtom); @@ -88,54 +76,135 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN ); const themeMode = normalizeMobileThemeMode(storedPreferences?.themeMode); const themeAppearance = themeMode === "system" ? systemColorScheme : themeMode; - const themeIds = useMemo( - () => resolveMobileThemeIds(storedPreferences ?? {}), - [storedPreferences], + const resolvedThemeIds = resolveMobileThemeIds(storedPreferences ?? {}); + const themeIds = useMemo( + () => ({ light: resolvedThemeIds.light, dark: resolvedThemeIds.dark }), + [resolvedThemeIds.dark, resolvedThemeIds.light], ); const themeId = themeIds[themeAppearance]; - const isReady = AsyncResult.isSuccess(preferencesResult) && !preferencesResult.waiting; + const activeThemeName = getMobileUniwindThemeName(themeId, themeAppearance); + const { baseFontSize, codeFontSize, codeWordBreak, terminalFontSize } = preferences; + const appearance = useMemo( + () => resolveAppearance({ baseFontSize, codeFontSize, codeWordBreak, terminalFontSize }), + [baseFontSize, codeFontSize, codeWordBreak, terminalFontSize], + ); + // Preference patches are optimistic. Keep controls interactive while a save is + // in flight so rapid theme choices can supersede one another immediately. + const isReady = AsyncResult.isSuccess(preferencesResult); + const runtimeState = useMemo( + () => ({ + baseFontSize, + themeAppearance, + themeMode, + }), + [baseFontSize, themeAppearance, themeMode], + ); + const appliedRuntimeStateRef = useRef(null); + const selectedThemeIdsRef = useRef(themeIds); - useLayoutEffect(() => { - applyAppearanceVariables(preferences.baseFontSize, themeIds); - Uniwind.setTheme(themeMode); - cacheTerminalFontSize(resolveAppearance(preferences).terminalFontSize); - }, [preferences, themeIds, themeMode]); + const applyThemeRuntime = useCallback((next: MobileThemeRuntimeState) => { + const operations = createMobileThemeRuntimeOperations(appliedRuntimeStateRef.current, next); + for (const operation of operations) { + if (operation.kind === "update-text-variables") { + Uniwind.updateCSSVariables(operation.themeName, operation.variables); + continue; + } + if (operation.kind === "set-appearance-mode") { + Appearance.setColorScheme( + operation.themeMode === "system" ? "unspecified" : operation.appearance, + ); + } + } + appliedRuntimeStateRef.current = next; + }, []); + + const syncThemeRuntime = useCallback( + (next: MobileThemeRuntimeState) => applyThemeRuntime(next), + [applyThemeRuntime], + ); const updatePreferences = useCallback( (patch: Partial) => { + startTransition(() => savePreferences(patch)); + }, + [savePreferences], + ); + + const updateThemePreferences = useCallback( + (patch: Partial) => { + // Theme selection owns the visible root ScopedTheme value. Keep its + // optimistic atom update urgent so the first frame after a press is the + // complete new palette rather than a deferred transition render. savePreferences(patch); }, [savePreferences], ); + useLayoutEffect(() => { + selectedThemeIdsRef.current = themeIds; + syncThemeRuntime(runtimeState); + cacheTerminalFontSize(appearance.terminalFontSize); + }, [appearance.terminalFontSize, runtimeState, syncThemeRuntime, themeIds]); + const setThemeIdForAppearance = useCallback( (appearance: MobileThemeAppearance, value: MobileThemeId) => { - updatePreferences( - createMobileThemeSelectionPatch(themeIds, themeAppearance, appearance, value), + const patch = createMobileThemeSelectionPatch( + selectedThemeIdsRef.current, + themeAppearance, + appearance, + value, ); + selectedThemeIdsRef.current = resolveMobileThemeIds(patch); + updateThemePreferences(patch); }, - [themeAppearance, themeIds, updatePreferences], + [themeAppearance, updateThemePreferences], ); const setThemeIdForBothAppearances = useCallback( (value: MobileThemeId) => { - updatePreferences(createMobileThemePairPatch(value)); + const patch = createMobileThemePairPatch(value); + selectedThemeIdsRef.current = resolveMobileThemeIds(patch); + updateThemePreferences(patch); }, - [updatePreferences], + [updateThemePreferences], ); const setThemeMode = useCallback( (value: MobileThemeMode) => { - updatePreferences({ themeMode: value }); + const current = appliedRuntimeStateRef.current ?? runtimeState; + + // Clear a forced native appearance before publishing System. The + // resulting useColorScheme notification still sees the previous forced + // preference, so React batches the actual system palette into the one + // urgent preference commit below. + if (value === "system") { + Appearance.setColorScheme("unspecified"); + } + const nextAppearance = + value === "system" ? (Appearance.getColorScheme() === "dark" ? "dark" : "light") : value; + const next = { + ...current, + themeAppearance: nextAppearance, + themeMode: value, + }; + + updateThemePreferences({ themeMode: value }); + if (value === "system") { + appliedRuntimeStateRef.current = next; + } else { + syncThemeRuntime(next); + } }, - [updatePreferences], + [runtimeState, syncThemeRuntime, updateThemePreferences], ); const setBaseFontSize = useCallback( (value: number) => { + const current = appliedRuntimeStateRef.current ?? runtimeState; + syncThemeRuntime({ ...current, baseFontSize: value }); updatePreferences({ baseFontSize: value }); }, - [updatePreferences], + [runtimeState, syncThemeRuntime, updatePreferences], ); const setTerminalFontSize = useCallback( @@ -161,7 +230,7 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN const value = useMemo( (): AppearancePreferencesContextValue => ({ - appearance: resolveAppearance(preferences), + appearance, themeId, themeIds, themeMode, @@ -176,7 +245,7 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN setCodeWordBreak, }), [ - preferences, + appearance, themeId, themeIds, themeMode, @@ -194,7 +263,7 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN return ( - {props.children} + {props.children} ); } diff --git a/apps/mobile/src/features/settings/appearance/components/AppearancePreviews.tsx b/apps/mobile/src/features/settings/appearance/components/AppearancePreviews.tsx index f9275eb37385..55bd661a64ef 100644 --- a/apps/mobile/src/features/settings/appearance/components/AppearancePreviews.tsx +++ b/apps/mobile/src/features/settings/appearance/components/AppearancePreviews.tsx @@ -5,7 +5,7 @@ import { resolveMarkdownFontSizes, resolveMobileCodeSurface, } from "../../../../lib/appearancePreferences"; -import { useThemeColor } from "../../../../lib/useThemeColor"; +import { useUniwindTheme } from "../../../../lib/useUniwindTheme"; import { getMobileTerminalTheme } from "../../../terminal/terminalTheme"; import { useAppearancePreferences } from "../AppearancePreferencesProvider"; @@ -138,8 +138,9 @@ export function CodeAppearancePreview(props: { readonly wordBreak: boolean; }) { const surface = resolveMobileCodeSurface(props.fontSize); - const lineNumberColor = useThemeColor("--color-icon-subtle"); - const keywordColor = useThemeColor("--color-md-link"); + const theme = useUniwindTheme(); + const lineNumberColor = theme["--color-icon-subtle"]; + const keywordColor = theme["--color-md-link"]; const lineNumber = (line: CodePreviewLine, index: number) => ( ["name"]; @@ -36,10 +36,9 @@ export function FontSizeSliderRow(props: { readonly value: number; readonly onChange: (value: number) => void; }) { - const icon = useThemeColor("--color-icon"); - const iconMuted = String(useThemeColor("--color-icon-muted")); - const trackColor = String(useThemeColor("--color-secondary-border")); - const fillColor = String(useThemeColor("--color-primary")); + const theme = useUniwindTheme(); + const trackColor = theme["--color-secondary-border"]; + const fillColor = theme["--color-primary"]; const latest = useRef(props); latest.current = props; @@ -141,7 +140,7 @@ export function FontSizeSliderRow(props: { @@ -152,7 +151,7 @@ export function FontSizeSliderRow(props: { @@ -204,7 +203,7 @@ export function FontSizeSliderRow(props: { diff --git a/apps/mobile/src/features/settings/appearance/sections/ThemeAppearanceSection.tsx b/apps/mobile/src/features/settings/appearance/sections/ThemeAppearanceSection.tsx index ab2a99313985..2257115828b6 100644 --- a/apps/mobile/src/features/settings/appearance/sections/ThemeAppearanceSection.tsx +++ b/apps/mobile/src/features/settings/appearance/sections/ThemeAppearanceSection.tsx @@ -1,22 +1,22 @@ import { memo, useId } from "react"; import { Pressable, View } from "react-native"; import Svg, { Circle, Defs, RadialGradient, Stop } from "react-native-svg"; +import { ScopedTheme } from "uniwind"; import { mixThemePreviewBase, THEME_PREVIEW_RENDER_SPECS } from "@t3tools/shared/themePreview"; import { SymbolView } from "../../../../components/AppSymbol"; import { AppText as Text } from "../../../../components/AppText"; import { - getMobileThemeVariables, getMobileThemePreviewColors, MOBILE_THEME_OPTIONS, type MobileThemeAppearance, type MobileThemeId, type MobileThemeIds, type MobileThemeMode, - type MobileThemeVariables, } from "../../../../lib/mobileTheme"; -import { useThemeColor } from "../../../../lib/useThemeColor"; +import { getMobileUniwindThemeName } from "../../../../lib/mobileThemeRuntime"; +import { cn } from "../../../../lib/cn"; import { useAppearancePreferences } from "../AppearancePreferencesProvider"; const APPEARANCE_MODES: ReadonlyArray<{ @@ -28,6 +28,8 @@ const APPEARANCE_MODES: ReadonlyArray<{ { id: "dark", label: "Dark" }, ]; +const previewPercentage = (value: number) => `${value * 100}%`; + const PreviewOrb = memo(function PreviewOrb(props: { readonly appearance: MobileThemeAppearance; readonly compact?: boolean; @@ -46,9 +48,6 @@ const PreviewOrb = memo(function PreviewOrb(props: { Math.max(spec.action.center[0], 1 - spec.action.center[0]), Math.max(spec.action.center[1], 1 - spec.action.center[1]), ); - const position = (value: number) => `${value * 100}%`; - const radius = (value: number) => `${value * 100}%`; - return ( @@ -118,32 +117,26 @@ function ThemeCard(props: { readonly onSelect: (appearance: MobileThemeAppearance) => void; readonly themeId: MobileThemeId; }) { - const badgeBackground = useThemeColor("--color-card"); - const badgeIcon = useThemeColor("--color-icon"); - const choice = (appearance: MobileThemeAppearance, selected: boolean) => ( props.onSelect(appearance)} > {selected ? ( - + @@ -158,7 +151,10 @@ function ThemeCard(props: { accessibilityHint="Sets both light and dark appearances" accessibilityLabel={`${props.label} theme`} accessibilityRole="button" - accessibilityState={{ disabled: props.disabled }} + accessibilityState={{ + disabled: props.disabled, + selected: props.lightSelected && props.darkSelected, + }} className="absolute inset-0 rounded-[24px] active:bg-subtle" disabled={props.disabled} onPress={props.onSelectBoth} @@ -167,34 +163,26 @@ function ThemeCard(props: { {choice("light", props.lightSelected)} {choice("dark", props.darkSelected)} - - - {props.label} - - + + {props.label} + ); } -function PreviewPane(props: { readonly colors: MobileThemeVariables; readonly compact?: boolean }) { +function PreviewPane(props: { readonly compact?: boolean }) { return ( - + - - + + - - + + - - + + @@ -228,50 +204,31 @@ function PreviewPane(props: { readonly colors: MobileThemeVariables; readonly co } function ModePreview(props: { readonly mode: MobileThemeMode; readonly themeIds: MobileThemeIds }) { - const light = getMobileThemeVariables(props.themeIds.light, "light"); - const dark = getMobileThemeVariables(props.themeIds.dark, "dark"); - const currentBorder = useThemeColor("--color-border"); - const currentFrame = useThemeColor("--color-drawer"); - const currentIndicator = useThemeColor("--color-foreground-muted"); - const frameColor = - props.mode === "light" - ? light["--color-border"] - : props.mode === "dark" - ? dark["--color-border"] - : currentBorder; - const frameBackground = - props.mode === "light" - ? light["--color-drawer"] - : props.mode === "dark" - ? dark["--color-drawer"] - : currentFrame; - const indicatorColor = - props.mode === "light" - ? light["--color-foreground-muted"] - : props.mode === "dark" - ? dark["--color-foreground-muted"] - : currentIndicator; + if (props.mode === "system") { + return ( + + + + + + + + + + + + ); + } return ( - - - {props.mode === "system" ? ( - <> - - - - ) : ( - - )} + + + + + + - - + ); } @@ -288,11 +245,10 @@ function ModeCard(props: { accessibilityLabel={`${props.label} appearance`} accessibilityRole="radio" accessibilityState={{ checked: props.selected, disabled: props.disabled }} - className={ - props.selected - ? "min-w-0 flex-1 gap-2 rounded-[24px] border-2 border-primary bg-subtle p-2" - : "min-w-0 flex-1 gap-2 rounded-[24px] border border-border bg-card p-2" - } + className={cn( + "min-w-0 flex-1 gap-2 rounded-[24px] p-2 active:scale-[0.97]", + props.selected ? "border-2 border-primary bg-subtle" : "border border-border bg-card", + )} disabled={props.disabled} onPress={props.onPress} > diff --git a/apps/mobile/src/features/settings/appearance/useScaledTextRole.ts b/apps/mobile/src/features/settings/appearance/useScaledTextRole.ts index 4224740c26f3..62f918a0e6b0 100644 --- a/apps/mobile/src/features/settings/appearance/useScaledTextRole.ts +++ b/apps/mobile/src/features/settings/appearance/useScaledTextRole.ts @@ -1,18 +1,12 @@ -import { useCSSVariable } from "uniwind"; +import { useMemo } from "react"; +import { + DEFAULT_BASE_FONT_SIZE, + normalizeBaseFontSize, + scaledTypographyLineHeight, +} from "../../../lib/appearancePreferences"; import { MOBILE_TYPOGRAPHY } from "../../../lib/typography"; - -const TEXT_ROLE_VARIABLES = { - micro: "--text-3xs", - caption: "--text-2xs", - label: "--text-xs", - footnote: "--text-sm", - body: "--text-base", - headline: "--text-lg", - title: "--text-xl", - largeTitle: "--text-2xl", - display: "--text-3xl", -} as const satisfies Record; +import { useAppearancePreferences } from "./AppearancePreferencesProvider"; export interface ScaledTextRole { readonly fontSize: number; @@ -20,17 +14,21 @@ export interface ScaledTextRole { } /** - * Reads a typography role's current size from the Uniwind `--text-*` CSS - * variables (scaled at runtime with the base font size). Use for style-prop - * consumers that can't express their size as a `text-*` className. Reactive: - * re-renders when the appearance provider re-injects the variables. + * Mirrors the values injected into Uniwind for style-prop consumers that + * cannot use a `text-*` class. This deliberately does not subscribe to CSS + * variables, so palette-only setTheme calls remain native-only. */ export function useScaledTextRole(role: keyof typeof MOBILE_TYPOGRAPHY): ScaledTextRole { - const variable = TEXT_ROLE_VARIABLES[role]; - const [fontSize, lineHeight] = useCSSVariable([variable, `${variable}--line-height`]); - - return { - fontSize: typeof fontSize === "number" ? fontSize : MOBILE_TYPOGRAPHY[role].fontSize, - lineHeight: typeof lineHeight === "number" ? lineHeight : MOBILE_TYPOGRAPHY[role].lineHeight, - }; + const { appearance } = useAppearancePreferences(); + return useMemo(() => { + const baseFontSize = normalizeBaseFontSize(appearance.baseFontSize); + const typography = MOBILE_TYPOGRAPHY[role]; + return { + fontSize: Math.max( + 8, + Math.round(typography.fontSize * (baseFontSize / DEFAULT_BASE_FONT_SIZE)), + ), + lineHeight: scaledTypographyLineHeight(typography, baseFontSize), + }; + }, [appearance.baseFontSize, role]); } diff --git a/apps/mobile/src/features/settings/components/SettingsLegalDocumentRouteScreen.tsx b/apps/mobile/src/features/settings/components/SettingsLegalDocumentRouteScreen.tsx index aa5303b9a8a3..86e1008a14bd 100644 --- a/apps/mobile/src/features/settings/components/SettingsLegalDocumentRouteScreen.tsx +++ b/apps/mobile/src/features/settings/components/SettingsLegalDocumentRouteScreen.tsx @@ -6,12 +6,10 @@ import { WebView } from "react-native-webview"; import { AppText as Text } from "../../../components/AppText"; import { LoadingStrip } from "../../../components/LoadingStrip"; import { SymbolView } from "../../../components/AppSymbol"; -import { useThemeColor } from "../../../lib/useThemeColor"; import { isLegalDocumentUrl, LEGAL_URL } from "../lib/legal-document-url"; export function SettingsLegalDocumentCloseHeaderButton() { const navigation = useNavigation(); - const iconColor = useThemeColor("--color-icon"); return ( @@ -37,7 +35,6 @@ export function SettingsLegalDocumentExternalHeaderButton({ }: { readonly externalUrl?: string; }) { - const iconColor = useThemeColor("--color-icon"); const safeExternalUrl = isLegalDocumentUrl(externalUrl) ? externalUrl : LEGAL_URL; return ( @@ -51,7 +48,7 @@ export function SettingsLegalDocumentExternalHeaderButton({ @@ -69,7 +66,6 @@ export function SettingsLegalDocumentRouteScreen({ documentUrl, }: SettingsLegalDocumentRouteScreenProps) { const navigation = useNavigation>(); - const iconColor = useThemeColor("--color-icon"); const [reloadKey, setReloadKey] = useState(0); const [loadProgress, setLoadProgress] = useState(0); const [loadError, setLoadError] = useState(null); @@ -94,7 +90,7 @@ export function SettingsLegalDocumentRouteScreen({ diff --git a/apps/mobile/src/features/settings/components/SettingsRow.tsx b/apps/mobile/src/features/settings/components/SettingsRow.tsx index fcdcf7982fb9..f15f21b9ac8a 100644 --- a/apps/mobile/src/features/settings/components/SettingsRow.tsx +++ b/apps/mobile/src/features/settings/components/SettingsRow.tsx @@ -5,7 +5,6 @@ import { Pressable, View } from "react-native"; import { SymbolView } from "../../../components/AppSymbol"; import { AppText as Text } from "../../../components/AppText"; -import { useThemeColor } from "../../../lib/useThemeColor"; import type { SettingsLegalDocumentTarget, SettingsSheetTarget } from "./settings-sheet-targets"; type SymbolName = ComponentProps["name"]; @@ -20,8 +19,6 @@ export function SettingsRow(props: { readonly onPress?: () => void; }) { const navigation = useNavigation(); - const icon = useThemeColor("--color-icon"); - const chevron = useThemeColor("--color-chevron"); const content = ( - + {props.label} @@ -48,7 +51,7 @@ export function SettingsRow(props: { diff --git a/apps/mobile/src/features/settings/components/SettingsSwitchRow.tsx b/apps/mobile/src/features/settings/components/SettingsSwitchRow.tsx index 2a63385a04b1..3abda36af664 100644 --- a/apps/mobile/src/features/settings/components/SettingsSwitchRow.tsx +++ b/apps/mobile/src/features/settings/components/SettingsSwitchRow.tsx @@ -4,7 +4,6 @@ import { View } from "react-native"; import { SymbolView } from "../../../components/AppSymbol"; import { AppText as Text } from "../../../components/AppText"; import { ThemedSwitch } from "../../../components/ThemedSwitch"; -import { useThemeColor } from "../../../lib/useThemeColor"; type SymbolName = ComponentProps["name"]; @@ -16,8 +15,6 @@ export function SettingsSwitchRow(props: { readonly value: boolean; readonly onValueChange: (value: boolean) => void; }) { - const icon = useThemeColor("--color-icon"); - return ( - + {props.label} {props.subtitle ? ( diff --git a/apps/mobile/src/features/sharing/IncomingShareProvider.tsx b/apps/mobile/src/features/sharing/IncomingShareProvider.tsx index 9203e665190a..e25a3d5b92bd 100644 --- a/apps/mobile/src/features/sharing/IncomingShareProvider.tsx +++ b/apps/mobile/src/features/sharing/IncomingShareProvider.tsx @@ -1,5 +1,6 @@ import Constants from "expo-constants"; import * as Crypto from "expo-crypto"; +import { PROVIDER_SEND_TURN_MAX_FILE_BYTES } from "@t3tools/contracts"; import { clearSharedPayloads, getResolvedSharedPayloadsAsync, @@ -12,11 +13,13 @@ import { Alert, AppState, Platform } from "react-native"; import { buildIncomingShareDraft, + isShareFileUriUnderOwnedRoots, type IncomingShareDestination, type IncomingShareDraft, } from "./incoming-share-model"; import { createIncomingSharePayloadReader } from "./incoming-share-native"; import { IncomingShareInbox } from "./incoming-share-inbox"; +import { persistComposerAttachmentFile } from "../../lib/composerImages"; import { loadIncomingShareDrafts, removeIncomingShareDraft, @@ -54,7 +57,7 @@ const getIncomingSharePayloads = createIncomingSharePayloadReader({ readPayloads: getSharedPayloads, }); -async function resolvedPayloadsForImages(): Promise> { +async function resolvedPayloadsForFiles(): Promise> { try { return await getResolvedSharedPayloadsAsync(); } catch (error) { @@ -84,12 +87,29 @@ async function readBase64(uri: string): Promise { return new File(uri).base64(); } +async function readFileSize(uri: string): Promise { + const { File } = await import("expo-file-system"); + return new File(uri).size ?? null; +} + async function removeOwnedFile(uri: string): Promise { if (!uri.startsWith("file:")) { return; } try { - const { File } = await import("expo-file-system"); + const { File, Paths } = await import("expo-file-system"); + // Only delete files in directories this app owns: its documents and cache + // sandbox and its share-extension App Group container. An iOS + // open-in-place share points at the sender's own storage; deleting that + // URI would destroy the user's document. + const ownedRootUris = [ + Paths.document.uri, + Paths.cache.uri, + ...Object.values(Paths.appleSharedContainers ?? {}).map((directory) => directory.uri), + ]; + if (!isShareFileUriUnderOwnedRoots(uri, ownedRootUris)) { + return; + } const file = new File(uri); if (file.exists) { file.delete(); @@ -99,21 +119,23 @@ async function removeOwnedFile(uri: string): Promise { } } -async function removeReplayedImagePayloadFiles( - payloads: ReadonlyArray, -): Promise { +async function removeReplayedPayloadFiles(payloads: ReadonlyArray): Promise { const uris = new Set(); for (const payload of payloads) { - if (payload.shareType === "image") { + if (["image", "file", "audio", "video"].includes(payload.shareType)) { uris.add(payload.value); } } if (uris.size === 0) { return; } - const resolvedPayloads = await resolvedPayloadsForImages(); + const resolvedPayloads = payloads.some((payload) => + ["file", "audio", "video"].includes(payload.shareType), + ) + ? [] + : await resolvedPayloadsForFiles(); for (const payload of resolvedPayloads) { - if (payload.shareType === "image" && payload.contentUri) { + if (["image", "file", "audio", "video"].includes(payload.shareType) && payload.contentUri) { uris.add(payload.contentUri); } } @@ -131,14 +153,29 @@ const incomingShareInbox = new IncomingShareInbox({ clearPayloads: clearSharedPayloads, buildDraft: async ({ payloads, id, createdAt }) => { const cleanupUris = new Set(); - const resolvedPayloads = payloads.some((payload) => payload.shareType === "image") - ? await resolvedPayloadsForImages() - : []; + const persistedUris = new Set(); + const hasGenericFilePayload = payloads.some((payload) => + ["file", "audio", "video"].includes(payload.shareType), + ); + const resolvedPayloads = + !hasGenericFilePayload && payloads.some((payload) => payload.shareType === "image") + ? await resolvedPayloadsForFiles() + : []; const draft = await buildIncomingShareDraft({ payloads, resolvedPayloads, fileReader: { readBase64, + persistFile: async (uri, name) => { + const persistedUri = await persistComposerAttachmentFile( + uri, + name, + PROVIDER_SEND_TURN_MAX_FILE_BYTES, + ); + persistedUris.add(persistedUri); + return persistedUri; + }, + readSize: readFileSize, removeOwnedFile: (uri) => { cleanupUris.add(uri); }, @@ -151,9 +188,12 @@ const incomingShareInbox = new IncomingShareInbox({ cleanup: async () => { await Promise.all([...cleanupUris].map(removeOwnedFile)); }, + rollback: async () => { + await Promise.all([...persistedUris].map(removeOwnedFile)); + }, }; }, - cleanupReplayedPayloads: removeReplayedImagePayloadFiles, + cleanupReplayedPayloads: removeReplayedPayloadFiles, idForPayloads: incomingShareIdForPayloads, now: () => new Date().toISOString(), onClearError: (error) => { diff --git a/apps/mobile/src/features/sharing/incoming-share-inbox.test.ts b/apps/mobile/src/features/sharing/incoming-share-inbox.test.ts index ff50c6a917a6..251fa3757014 100644 --- a/apps/mobile/src/features/sharing/incoming-share-inbox.test.ts +++ b/apps/mobile/src/features/sharing/incoming-share-inbox.test.ts @@ -136,11 +136,13 @@ describe("IncomingShareInbox", () => { it("does not acknowledge a supported payload when its durable write fails", async () => { const clearPayloads = vi.fn(); const cleanup = vi.fn(async () => undefined); + const rollback = vi.fn(async () => undefined); const { inbox } = createHarness({ clearPayloads, buildDraft: async ({ id, createdAt }) => ({ draft: draft(id, createdAt), cleanup, + rollback, }), writeDraft: async () => { throw new Error("disk full"); @@ -150,6 +152,7 @@ describe("IncomingShareInbox", () => { await expect(inbox.refresh({ ingestNative: true })).rejects.toThrow("disk full"); expect(clearPayloads).not.toHaveBeenCalled(); expect(cleanup).not.toHaveBeenCalled(); + expect(rollback).toHaveBeenCalledOnce(); }); it("durably reserves a share for one project before draft import", async () => { diff --git a/apps/mobile/src/features/sharing/incoming-share-inbox.ts b/apps/mobile/src/features/sharing/incoming-share-inbox.ts index 1f61ea710bb5..ca9d65d36ae0 100644 --- a/apps/mobile/src/features/sharing/incoming-share-inbox.ts +++ b/apps/mobile/src/features/sharing/incoming-share-inbox.ts @@ -20,6 +20,7 @@ export interface IncomingShareInboxDependencies { }) => Promise<{ readonly draft: IncomingShareDraft; readonly cleanup: () => Promise; + readonly rollback?: () => Promise; }>; readonly cleanupReplayedPayloads?: (payloads: ReadonlyArray) => Promise; readonly idForPayloads: (payloads: ReadonlyArray) => Promise; @@ -116,7 +117,14 @@ export class IncomingShareInbox { // The durable inbox write is the transaction boundary. Never clear the // native handoff first: a process termination must leave one recoverable // copy on one side of the boundary. - await this.dependencies.writeDraft(draft); + try { + await this.dependencies.writeDraft(draft); + } catch (error) { + if (built.rollback) { + await this.cleanup(built.rollback); + } + throw error; + } await this.cleanup(built.cleanup); this.clearNativePayloads(); return sortAndDedupeIncomingShares([draft, ...persisted]); diff --git a/apps/mobile/src/features/sharing/incoming-share-model.test.ts b/apps/mobile/src/features/sharing/incoming-share-model.test.ts index 07ede18b8ef8..ce2011650e71 100644 --- a/apps/mobile/src/features/sharing/incoming-share-model.test.ts +++ b/apps/mobile/src/features/sharing/incoming-share-model.test.ts @@ -1,11 +1,18 @@ import { describe, expect, it, vi } from "@effect/vitest"; import { PROVIDER_SEND_TURN_MAX_ATTACHMENTS, + PROVIDER_SEND_TURN_MAX_FILE_BYTES, PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, } from "@t3tools/contracts"; import type { ResolvedSharePayload, SharePayload } from "expo-sharing"; -import { buildIncomingShareDraft, hasIncomingShareContent } from "./incoming-share-model"; +import { + buildIncomingShareDraft, + hasIncomingShareContent, + isShareFileUriUnderOwnedRoots, + selectIncomingShareAttachments, + selectIncomingShareAttachmentsForServer, +} from "./incoming-share-model"; describe("incoming native shares", () => { it("converts shared text, URLs, and images into a durable composer draft", async () => { @@ -96,6 +103,459 @@ describe("incoming native shares", () => { expect(hasIncomingShareContent(result)).toBe(false); }); + it("keeps a shared PDF on disk without converting its contents to base64", async () => { + const file: SharePayload = { + shareType: "file", + value: "file:///shared/report.pdf", + mimeType: "application/pdf", + }; + const readBase64 = vi.fn(async () => "unused"); + const persistFile = vi.fn(async () => "file:///documents/report.pdf"); + const removeOwnedFile = vi.fn(async (_uri: string) => undefined); + + const result = await buildIncomingShareDraft({ + id: "share-report", + createdAt: "2026-07-15T10:00:00.000Z", + payloads: [file], + resolvedPayloads: [ + { + ...file, + contentUri: file.value, + contentType: "file", + contentMimeType: "application/pdf", + contentSize: 42, + originalName: "report.pdf", + }, + ], + fileReader: { readBase64, persistFile, removeOwnedFile }, + }); + + expect(result.attachments).toEqual([ + { + id: "share-report:file:0", + type: "file", + name: "report.pdf", + mimeType: "application/pdf", + sizeBytes: 42, + fileUri: "file:///documents/report.pdf", + }, + ]); + expect(readBase64).not.toHaveBeenCalled(); + expect(persistFile).toHaveBeenCalledWith(file.value, "report.pdf"); + expect(removeOwnedFile).toHaveBeenCalledWith(file.value); + }); + + it("rejects shared files that exceed the generic attachment limit", async () => { + const file: SharePayload = { + shareType: "file", + value: "file:///shared/huge.zip", + mimeType: "application/zip", + }; + const persistFile = vi.fn(async () => "file:///documents/huge.zip"); + const removeOwnedFile = vi.fn(async () => undefined); + + const result = await buildIncomingShareDraft({ + id: "share-huge", + createdAt: "2026-07-15T10:00:00.000Z", + payloads: [file], + resolvedPayloads: [ + { + ...file, + contentUri: file.value, + contentType: "file", + contentMimeType: "application/zip", + contentSize: PROVIDER_SEND_TURN_MAX_FILE_BYTES + 1, + originalName: "huge.zip", + }, + ], + fileReader: { + readBase64: async () => "unused", + persistFile, + removeOwnedFile, + }, + }); + + expect(result.attachments).toEqual([]); + expect(result.warnings).toEqual(["'huge.zip' exceeds the 50 MB attachment limit."]); + expect(persistFile).not.toHaveBeenCalled(); + expect(removeOwnedFile).toHaveBeenCalledWith(file.value); + }); + + it.each([ + { value: "file:///shared/clip.MOV", mimeType: "video/quicktime", originalName: "clip.MOV" }, + { value: "content://media/videos/12", mimeType: "video/mp4", originalName: "clip.mp4" }, + ])("imports a shared video from $value without reading it as an image", async (video) => { + const sizeBytes = 20 * 1024 * 1024; + const fileUri = `file:///documents/${video.originalName}`; + const readBase64 = vi.fn(async () => "unused"); + const persistFile = vi.fn(async () => fileUri); + const removeOwnedFile = vi.fn(async () => undefined); + + const result = await buildIncomingShareDraft({ + id: "share-video", + createdAt: "2026-08-30T10:00:00.000Z", + payloads: [{ ...video, shareType: "video" }], + resolvedPayloads: [], + fileReader: { readBase64, persistFile, readSize: async () => sizeBytes, removeOwnedFile }, + }); + + expect(result.warnings).toEqual([]); + expect(result.attachments).toEqual([ + { + id: "share-video:file:0", + type: "file", + name: video.originalName, + mimeType: video.mimeType, + sizeBytes, + fileUri, + }, + ]); + expect(readBase64).not.toHaveBeenCalled(); + expect(removeOwnedFile).toHaveBeenCalledWith(video.value); + expect( + selectIncomingShareAttachments({ + attachments: result.attachments, + maxFileAttachmentBytes: 50 * 1024 * 1024, + }), + ).toEqual({ attachments: result.attachments, warnings: [] }); + expect( + selectIncomingShareAttachments({ + attachments: result.attachments, + maxFileAttachmentBytes: 10 * 1024 * 1024, + }), + ).toEqual({ + attachments: [], + warnings: [`'${video.originalName}' exceeds the 10 MB attachment limit.`], + }); + }); + + it("reports an unreadable shared file without calling it oversized", async () => { + const file: SharePayload = { + shareType: "file", + value: "file:///shared/empty.txt", + mimeType: "text/plain", + }; + + const result = await buildIncomingShareDraft({ + id: "share-empty", + createdAt: "2026-07-15T10:00:00.000Z", + payloads: [file], + resolvedPayloads: [], + fileReader: { + readBase64: async () => "unused", + readSize: async () => 0, + removeOwnedFile: async () => undefined, + }, + }); + + expect(result.attachments).toEqual([]); + expect(result.warnings).toEqual(["'empty.txt' is empty or could not be read."]); + }); + + it("reads an Android content URI's size after copying it into app-owned storage", async () => { + const file: SharePayload = { + shareType: "file", + value: "content://shared/report", + mimeType: "application/pdf", + }; + const persistFile = vi.fn(async () => "file:///documents/report.pdf"); + const readSize = vi.fn(async (uri: string) => (uri.startsWith("content:") ? null : 42)); + + const result = await buildIncomingShareDraft({ + id: "share-android-report", + createdAt: "2026-07-15T10:00:00.000Z", + payloads: [file], + resolvedPayloads: [], + fileReader: { + readBase64: async () => "unused", + persistFile, + readSize, + removeOwnedFile: async () => undefined, + }, + }); + + expect(result.attachments).toEqual([ + { + id: "share-android-report:file:0", + type: "file", + name: "report", + mimeType: "application/pdf", + sizeBytes: 42, + fileUri: "file:///documents/report.pdf", + }, + ]); + expect(readSize.mock.calls).toEqual([ + ["content://shared/report"], + ["file:///documents/report.pdf"], + ]); + }); + + it("records the stored copy's measured size when a content URI under-reports", async () => { + const file: SharePayload = { + shareType: "file", + value: "content://shared/report", + mimeType: "application/pdf", + }; + // The source claims 42 bytes but the stored copy measures 4200. + const persistFile = vi.fn(async () => "file:///documents/report.pdf"); + const readSize = vi.fn(async (uri: string) => (uri.startsWith("content:") ? 42 : 4200)); + + const result = await buildIncomingShareDraft({ + id: "share-android-report", + createdAt: "2026-07-15T10:00:00.000Z", + payloads: [file], + resolvedPayloads: [], + fileReader: { + readBase64: async () => "unused", + persistFile, + readSize, + removeOwnedFile: async () => undefined, + }, + }); + + expect(result.attachments).toHaveLength(1); + expect(result.attachments[0]?.sizeBytes).toBe(4200); + }); + + it("treats a zero-length Android content URI as unknown until its copy is measured", async () => { + const file: SharePayload = { + shareType: "file", + value: "content://shared/report", + mimeType: "application/pdf", + }; + + const result = await buildIncomingShareDraft({ + id: "share-zero-metadata", + createdAt: "2026-07-15T10:00:00.000Z", + payloads: [file], + resolvedPayloads: [], + fileReader: { + readBase64: async () => "unused", + persistFile: async () => "file:///documents/report.pdf", + readSize: async (uri) => (uri.startsWith("content:") ? 0 : 42), + removeOwnedFile: async () => undefined, + }, + }); + + expect(result.attachments[0]?.sizeBytes).toBe(42); + expect(result.warnings).toEqual([]); + }); + + it("rejects a shared file whose persisted copy measures empty and releases the copy", async () => { + const file: SharePayload = { + shareType: "file", + value: "content://shared/report", + mimeType: "application/pdf", + }; + const persistedUri = "file:///documents/report.pdf"; + const removeOwnedFile = vi.fn(async (_uri: string) => undefined); + + const result = await buildIncomingShareDraft({ + id: "share-empty-copy", + createdAt: "2026-07-15T10:00:00.000Z", + payloads: [file], + resolvedPayloads: [], + fileReader: { + readBase64: async () => "unused", + persistFile: async () => persistedUri, + // The source claims 42 bytes but the stored copy measures zero: the + // copy is what uploads, so its measured size wins and the empty file + // is rejected instead of shipped with a made-up size. + readSize: async (uri) => (uri.startsWith("content:") ? 42 : 0), + removeOwnedFile, + }, + }); + + expect(result.attachments).toEqual([]); + expect(result.warnings).toEqual(["'report' is empty or could not be read."]); + expect(removeOwnedFile.mock.calls.map(([uri]) => uri)).toContain(persistedUri); + }); + + it("keeps the Android display name without copying the file into the Expo cache", async () => { + const file = { + shareType: "file" as const, + value: "content://shared/12345", + mimeType: "application/pdf", + originalName: "quarterly-report.pdf", + }; + + const result = await buildIncomingShareDraft({ + id: "share-named-report", + createdAt: "2026-07-15T10:00:00.000Z", + payloads: [file], + resolvedPayloads: [], + fileReader: { + readBase64: async () => "unused", + readSize: async () => 42, + persistFile: async (_uri, name) => `file:///documents/${name}`, + removeOwnedFile: async () => undefined, + }, + }); + + expect(result.attachments).toEqual([ + { + id: "share-named-report:file:0", + type: "file", + name: "quarterly-report.pdf", + mimeType: "application/pdf", + sizeBytes: 42, + fileUri: "file:///documents/quarterly-report.pdf", + }, + ]); + }); + + it("keeps a no-copy file source that the returned attachment still owns", async () => { + const sourceUri = "file:///documents/report.pdf"; + const removeOwnedFile = vi.fn(async () => undefined); + + const result = await buildIncomingShareDraft({ + id: "share-no-copy", + createdAt: "2026-07-15T10:00:00.000Z", + payloads: [{ shareType: "file", value: sourceUri, mimeType: "application/pdf" }], + resolvedPayloads: [], + fileReader: { + readBase64: async () => "unused", + readSize: async () => 42, + removeOwnedFile, + }, + }); + + expect(result.attachments[0]).toMatchObject({ type: "file", fileUri: sourceUri }); + expect(removeOwnedFile).not.toHaveBeenCalled(); + }); + + it("keeps a persisted copy and releases distinct temporary source URIs", async () => { + const payloadUri = "content://shared/report"; + const resolvedUri = "file:///cache/report.pdf"; + const persistedUri = "file:///documents/report.pdf"; + const removeOwnedFile = vi.fn(async (_uri: string) => undefined); + + const result = await buildIncomingShareDraft({ + id: "share-copy", + createdAt: "2026-07-15T10:00:00.000Z", + payloads: [{ shareType: "file", value: payloadUri, mimeType: "application/pdf" }], + resolvedPayloads: [ + { + shareType: "file", + value: payloadUri, + mimeType: "application/pdf", + contentUri: resolvedUri, + contentType: "file", + contentMimeType: "application/pdf", + contentSize: 42, + originalName: "report.pdf", + }, + ], + fileReader: { + readBase64: async () => "unused", + readSize: async () => 42, + persistFile: async () => persistedUri, + removeOwnedFile, + }, + }); + + expect(result.attachments[0]).toMatchObject({ type: "file", fileUri: persistedUri }); + expect(removeOwnedFile.mock.calls.map(([uri]) => uri)).toEqual([resolvedUri, payloadUri]); + }); + + it("keeps images and rejects shared files on servers without file support", () => { + const image = { + id: "image-1", + type: "image" as const, + name: "image.png", + mimeType: "image/png", + sizeBytes: 3, + dataUrl: "data:image/png;base64,YWJj", + previewUri: "data:image/png;base64,YWJj", + }; + const file = { + id: "file-1", + type: "file" as const, + name: "report.pdf", + mimeType: "application/pdf", + sizeBytes: 42, + fileUri: "file:///documents/report.pdf", + }; + + expect( + selectIncomingShareAttachments({ + attachments: [image, file], + maxFileAttachmentBytes: null, + }), + ).toEqual({ + attachments: [image], + warnings: ["'report.pdf' was skipped because this server does not support files."], + }); + }); + + it("uses the destination server's attachment limit in share warnings", () => { + const file = { + id: "file-1", + type: "file" as const, + name: "report.pdf", + mimeType: "application/pdf", + sizeBytes: 6 * 1024 * 1024, + fileUri: "file:///documents/report.pdf", + }; + + expect( + selectIncomingShareAttachments({ + attachments: [file], + maxFileAttachmentBytes: 5 * 1024 * 1024, + }), + ).toEqual({ + attachments: [], + warnings: ["'report.pdf' exceeds the 5 MB attachment limit."], + }); + }); + + it("uses current server support and limits when selecting a reserved share", () => { + const file = { + id: "file-1", + type: "file" as const, + name: "report.pdf", + mimeType: "application/pdf", + sizeBytes: 6 * 1024 * 1024, + fileUri: "file:///documents/report.pdf", + }; + + expect( + selectIncomingShareAttachmentsForServer({ attachments: [file], serverConfig: null }), + ).toEqual({ status: "pending" }); + expect( + selectIncomingShareAttachmentsForServer({ + attachments: [file], + serverConfig: { environment: { capabilities: { attachmentUploads: true } } }, + }), + ).toMatchObject({ status: "ready", attachments: [] }); + expect( + selectIncomingShareAttachmentsForServer({ + attachments: [file], + serverConfig: { + environment: { + capabilities: { + attachmentUploads: true, + fileAttachments: { maxUploadBytes: 5 * 1024 * 1024 }, + }, + }, + }, + }), + ).toMatchObject({ status: "ready", attachments: [] }); + expect( + selectIncomingShareAttachmentsForServer({ + attachments: [file], + serverConfig: { + environment: { + capabilities: { + attachmentUploads: true, + fileAttachments: { maxUploadBytes: 10 * 1024 * 1024 }, + }, + }, + }, + }), + ).toMatchObject({ status: "ready", attachments: [file] }); + }); + it("releases every temporary file when a share exceeds the attachment limit", async () => { const payloads = Array.from({ length: PROVIDER_SEND_TURN_MAX_ATTACHMENTS + 1 }, (_, index) => ({ shareType: "image" as const, @@ -193,4 +653,109 @@ describe("incoming native shares", () => { expect(result.attachments).toHaveLength(1); expect(result.warnings).toEqual([]); }); + + it("releases a persisted copy when a later step fails to read it", async () => { + const file: SharePayload = { + shareType: "file", + value: "content://shared/report", + mimeType: "application/pdf", + }; + const persistedUri = "file:///documents/t3-composer-attachments/report.pdf"; + const removeOwnedFile = vi.fn(async (_uri: string) => undefined); + + const result = await buildIncomingShareDraft({ + id: "share-persist-leak", + createdAt: "2026-07-16T08:00:00.000Z", + payloads: [file], + resolvedPayloads: [], + fileReader: { + readBase64: async () => "unused", + persistFile: async () => persistedUri, + readSize: async (uri) => { + if (uri === persistedUri) { + throw new Error("read failed"); + } + return null; + }, + removeOwnedFile, + }, + }); + + expect(result.attachments).toEqual([]); + expect(result.warnings).toEqual(["read failed"]); + expect(removeOwnedFile.mock.calls.map(([uri]) => uri)).toContain(persistedUri); + }); +}); + +describe("share cleanup ownership", () => { + const ownedRoots = [ + "file:///var/mobile/Containers/Data/Application/APP/Documents/", + "file:///var/mobile/Containers/Shared/AppGroup/GROUP", + ]; + + it("allows deleting files inside the app's own directories", () => { + expect( + isShareFileUriUnderOwnedRoots( + "file:///var/mobile/Containers/Shared/AppGroup/GROUP/shared.pdf", + ownedRoots, + ), + ).toBe(true); + }); + + it("treats /private/var and /var as the same iOS location", () => { + expect( + isShareFileUriUnderOwnedRoots( + "file:///private/var/mobile/Containers/Shared/AppGroup/GROUP/shared.pdf", + ownedRoots, + ), + ).toBe(true); + expect( + isShareFileUriUnderOwnedRoots( + "file:///var/mobile/Containers/Data/Application/APP/Documents/t3-composer-attachments/a.pdf", + ["file:///private/var/mobile/Containers/Data/Application/APP/Documents/"], + ), + ).toBe(true); + }); + + it("refuses to delete a sender-owned open-in-place document", () => { + expect( + isShareFileUriUnderOwnedRoots( + "file:///private/var/mobile/Containers/Shared/FileProvider/OTHER/File%20Provider%20Storage/taxes.pdf", + ownedRoots, + ), + ).toBe(false); + }); + + it("refuses traversal segments that escape an owned root", () => { + // An encoded separator survives URL normalization: "..%2F.." decodes to + // "../..", so the lexical check must reject it before containment. + expect( + isShareFileUriUnderOwnedRoots( + "file:///var/mobile/Containers/Shared/AppGroup/GROUP/..%2F..%2FsenderDoc.pdf", + ownedRoots, + ), + ).toBe(false); + expect( + isShareFileUriUnderOwnedRoots( + "file:///var/mobile/Containers/Shared/AppGroup/GROUP/../senderDoc.pdf", + ownedRoots, + ), + ).toBe(false); + expect( + isShareFileUriUnderOwnedRoots( + "file:///var/mobile/Containers/Shared/AppGroup/GROUP/%2e%2e/senderDoc.pdf", + ownedRoots, + ), + ).toBe(false); + }); + + it("refuses non-file URIs and the owned root itself", () => { + expect(isShareFileUriUnderOwnedRoots("content://shared/report", ownedRoots)).toBe(false); + expect( + isShareFileUriUnderOwnedRoots( + "file:///var/mobile/Containers/Shared/AppGroup/GROUP", + ownedRoots, + ), + ).toBe(false); + }); }); diff --git a/apps/mobile/src/features/sharing/incoming-share-model.ts b/apps/mobile/src/features/sharing/incoming-share-model.ts index d9985a700051..a12343dfa4e2 100644 --- a/apps/mobile/src/features/sharing/incoming-share-model.ts +++ b/apps/mobile/src/features/sharing/incoming-share-model.ts @@ -1,13 +1,18 @@ +import { + clampFileAttachmentUploadBytes, + fileAttachmentTooLargeMessage, +} from "@t3tools/client-runtime/state/attachments"; import { isProviderSendTurnSupportedImageMimeType, PROVIDER_SEND_TURN_MAX_ATTACHMENTS, + PROVIDER_SEND_TURN_MAX_FILE_BYTES, PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; import type { ResolvedSharePayload, SharePayload } from "expo-sharing"; -import { DraftComposerImageAttachmentSchema } from "../../lib/composer-image-schema"; -import type { DraftComposerImageAttachment } from "../../lib/composerImages"; +import { DraftComposerAttachmentSchema } from "../../lib/composer-image-schema"; +import type { DraftComposerAttachment } from "../../lib/composerImages"; import { estimateBase64ByteSize } from "../../lib/base64"; export interface IncomingShareDraft { @@ -16,7 +21,7 @@ export interface IncomingShareDraft { readonly createdAt: string; readonly destination?: IncomingShareDestination; readonly text: string; - readonly attachments: ReadonlyArray; + readonly attachments: ReadonlyArray; readonly warnings: ReadonlyArray; } @@ -36,7 +41,7 @@ export const IncomingShareDraftSchema = Schema.Struct({ createdAt: Schema.String, destination: Schema.optional(IncomingShareDestinationSchema), text: Schema.String, - attachments: Schema.Array(DraftComposerImageAttachmentSchema), + attachments: Schema.Array(DraftComposerAttachmentSchema), warnings: Schema.Array(Schema.String), }); @@ -46,9 +51,126 @@ export function decodeIncomingShareDraft(value: unknown): IncomingShareDraft { return decodeIncomingShareDraftSync(value); } +/** + * `file:` path with the iOS `/private` prefix stripped, so URIs that reach the + * same file through the `/var` symlink and through `/private/var` compare + * equal. Null for anything that is not a `file:` URI. + */ +function normalizedFileUriPath(uri: string): string | null { + try { + const url = new URL(uri); + if (url.protocol !== "file:") { + return null; + } + const path = decodeURIComponent(url.pathname); + // URL parsing collapses literal ".." segments, but an encoded separator + // survives it: "..%2F.." decodes to "../..", which the filesystem would + // resolve outside the root the lexical containment check accepted. + if (path.split("/").includes("..")) { + return null; + } + return path.startsWith("/private/var/") ? path.slice("/private".length) : path; + } catch { + return null; + } +} + +/** + * Whether a shared `file:` URI points strictly inside one of the directories + * this app owns (its sandbox and its share-extension App Group container). + * Share cleanup must never delete anything else: an iOS open-in-place share + * hands over the sender's own file URL, and deleting it destroys the user's + * document. + */ +export function isShareFileUriUnderOwnedRoots( + uri: string, + ownedRootUris: ReadonlyArray, +): boolean { + const path = normalizedFileUriPath(uri); + if (path === null) { + return false; + } + return ownedRootUris.some((rootUri) => { + const rootPath = normalizedFileUriPath(rootUri); + if (rootPath === null) { + return false; + } + const root = rootPath.endsWith("/") ? rootPath : `${rootPath}/`; + return path.startsWith(root) && path.length > root.length; + }); +} + export interface IncomingShareFileReader { readonly readBase64: (uri: string) => Promise; readonly removeOwnedFile: (uri: string) => Promise | void; + readonly persistFile?: (uri: string, name: string) => Promise; + readonly readSize?: (uri: string) => Promise; +} + +/** Apply the destination server's file support after the user chooses a project. */ +export function selectIncomingShareAttachments(input: { + readonly attachments: ReadonlyArray; + readonly maxFileAttachmentBytes: number | null; +}): { + readonly attachments: ReadonlyArray; + readonly warnings: ReadonlyArray; +} { + const attachments: DraftComposerAttachment[] = []; + const warnings: string[] = []; + + for (const attachment of input.attachments) { + if (attachment.type === "image") { + attachments.push(attachment); + continue; + } + if (input.maxFileAttachmentBytes === null) { + warnings.push(`'${attachment.name}' was skipped because this server does not support files.`); + continue; + } + const maxFileAttachmentBytes = clampFileAttachmentUploadBytes(input.maxFileAttachmentBytes); + if (attachment.sizeBytes > maxFileAttachmentBytes) { + warnings.push(fileAttachmentTooLargeMessage(attachment.name, maxFileAttachmentBytes)); + continue; + } + attachments.push(attachment); + } + + return { attachments, warnings }; +} + +export function selectIncomingShareAttachmentsForServer(input: { + readonly attachments: ReadonlyArray; + readonly serverConfig: { + readonly environment: { + readonly capabilities: { + readonly attachmentUploads?: boolean; + readonly fileAttachments?: { readonly maxUploadBytes: number }; + }; + }; + } | null; +}): + | { readonly status: "pending" } + | { + readonly status: "ready"; + readonly attachments: ReadonlyArray; + readonly warnings: ReadonlyArray; + } { + const hasFiles = input.attachments.some((attachment) => attachment.type === "file"); + if (hasFiles && input.serverConfig === null) { + return { status: "pending" }; + } + const capabilities = input.serverConfig?.environment.capabilities; + const maxFileAttachmentBytes = + capabilities?.attachmentUploads === true + ? (capabilities.fileAttachments?.maxUploadBytes ?? null) + : null; + return { + status: "ready", + ...selectIncomingShareAttachments({ + attachments: input.attachments, + maxFileAttachmentBytes, + }), + }; } function sharedText(payloads: ReadonlyArray): string { @@ -119,8 +241,11 @@ function fallbackName(uri: string, index: number, mimeType: string): string { } catch { // Fall through to a deterministic attachment name. } - const extension = mimeType.split("/")[1]?.replace(/[^a-z0-9.+-]/gi, "") || "png"; - return `shared-image-${index + 1}.${extension}`; + const family = mimeType.split("/")[0]?.toLowerCase(); + const kind = family === "image" || family === "audio" || family === "video" ? family : "file"; + const extension = + mimeType.split("/")[1]?.replace(/[^a-z0-9.+-]/gi, "") || (kind === "image" ? "png" : "bin"); + return `shared-${kind}-${index + 1}.${extension}`; } export async function buildIncomingShareDraft(input: { @@ -130,13 +255,18 @@ export async function buildIncomingShareDraft(input: { readonly id: string; readonly createdAt: string; }): Promise { - const attachments: DraftComposerImageAttachment[] = []; + const attachments: DraftComposerAttachment[] = []; const warnings: string[] = []; const consumedResolvedPayloadIndexes = new Set(); let warnedAttachmentLimit = false; for (const [index, payload] of input.payloads.entries()) { - if (payload.shareType !== "image") { + if ( + payload.shareType !== "image" && + payload.shareType !== "file" && + payload.shareType !== "audio" && + payload.shareType !== "video" + ) { continue; } const resolved = resolvedImageFor( @@ -149,7 +279,7 @@ export async function buildIncomingShareDraft(input: { if (attachments.length >= PROVIDER_SEND_TURN_MAX_ATTACHMENTS) { if (!warnedAttachmentLimit) { warnings.push( - `Only the first ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} shared images were attached.`, + `Only the first ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} shared ${payload.shareType === "image" ? "images" : "files"} were attached.`, ); warnedAttachmentLimit = true; } @@ -157,7 +287,100 @@ export async function buildIncomingShareDraft(input: { continue; } - const mimeType = (resolved?.contentMimeType ?? payload.mimeType ?? "image/png").toLowerCase(); + const mimeType = ( + resolved?.contentMimeType ?? + payload.mimeType ?? + (payload.shareType === "image" ? "image/png" : "application/octet-stream") + ).toLowerCase(); + if (payload.shareType !== "image") { + // The patched native module never emits a blank display name, but keep + // the guard: an empty name would fail the attachment name contract. + const sharedFileName = + typeof payload.originalName === "string" && payload.originalName.trim().length > 0 + ? payload.originalName + : undefined; + const name = resolved?.originalName ?? sharedFileName ?? fallbackName(uri, index, mimeType); + if (!uri) { + warnings.push("One shared file could not be read."); + continue; + } + let persistedFileUri: string | undefined; + let retainedFileUri: string | undefined; + try { + let sizeBytes = resolved?.contentSize ?? (await input.fileReader.readSize?.(uri)) ?? null; + if ( + (sizeBytes === null || (sizeBytes === 0 && uri.startsWith("content:"))) && + input.fileReader.persistFile + ) { + persistedFileUri = await input.fileReader.persistFile(uri, name); + sizeBytes = (await input.fileReader.readSize?.(persistedFileUri)) ?? null; + } + if (sizeBytes === null) { + warnings.push(`The size of '${name}' could not be determined.`); + if (persistedFileUri) { + await releaseOwnedFiles(input.fileReader, [persistedFileUri]); + } + continue; + } + if (sizeBytes <= 0) { + warnings.push(`'${name}' is empty or could not be read.`); + if (persistedFileUri) { + await releaseOwnedFiles(input.fileReader, [persistedFileUri]); + } + continue; + } + if (sizeBytes > PROVIDER_SEND_TURN_MAX_FILE_BYTES) { + warnings.push(fileAttachmentTooLargeMessage(name, PROVIDER_SEND_TURN_MAX_FILE_BYTES)); + if (persistedFileUri) { + await releaseOwnedFiles(input.fileReader, [persistedFileUri]); + } + continue; + } + if (persistedFileUri === undefined && input.fileReader.persistFile) { + persistedFileUri = await input.fileReader.persistFile(uri, name); + // An Android content: source can misreport its size while the + // stored copy is what uploads, so the copy's measured size is what + // the attachment must record. A measured zero means the copy holds + // no bytes: reject it, whatever the source claimed. + const storedSize = (await input.fileReader.readSize?.(persistedFileUri)) ?? null; + if (storedSize !== null) { + sizeBytes = storedSize; + } + if (sizeBytes <= 0) { + warnings.push(`'${name}' is empty or could not be read.`); + await releaseOwnedFiles(input.fileReader, [persistedFileUri]); + continue; + } + if (sizeBytes > PROVIDER_SEND_TURN_MAX_FILE_BYTES) { + warnings.push(fileAttachmentTooLargeMessage(name, PROVIDER_SEND_TURN_MAX_FILE_BYTES)); + await releaseOwnedFiles(input.fileReader, [persistedFileUri]); + continue; + } + } + attachments.push({ + id: `${input.id}:file:${index}`, + type: "file", + name, + mimeType, + sizeBytes, + fileUri: persistedFileUri ?? uri, + }); + retainedFileUri = persistedFileUri ?? uri; + } catch (error) { + warnings.push(error instanceof Error ? error.message : `Could not read '${name}'.`); + // A copy persisted before the failure has no attachment referencing + // it; release it or it leaks in the app's attachment directory. + if (persistedFileUri !== undefined) { + await releaseOwnedFiles(input.fileReader, [persistedFileUri]); + } + } finally { + await releaseOwnedFiles( + input.fileReader, + [uri, payload.value].filter((candidate) => candidate !== retainedFileUri), + ); + } + continue; + } if (!uri || !mimeType.startsWith("image/")) { warnings.push("One shared item was not a supported image."); await releaseOwnedFiles(input.fileReader, [uri, payload.value]); diff --git a/apps/mobile/src/features/sharing/incoming-share-storage.test.ts b/apps/mobile/src/features/sharing/incoming-share-storage.test.ts new file mode 100644 index 000000000000..44f20eff9036 --- /dev/null +++ b/apps/mobile/src/features/sharing/incoming-share-storage.test.ts @@ -0,0 +1,78 @@ +import { afterEach, describe, expect, it } from "@effect/vitest"; +import { vi } from "vite-plus/test"; + +const fileSystemMocks = vi.hoisted(() => { + let entries: File[] = []; + + class File { + readonly exists = true; + + constructor( + readonly name: string, + private readonly contents: string, + ) {} + + async text(): Promise { + return this.contents; + } + } + + class Directory { + create(): void {} + + list(): ReadonlyArray { + return entries; + } + } + + return { + Directory, + File, + setEntries(next: File[]) { + entries = next; + }, + }; +}); + +vi.mock("expo-file-system", () => ({ + Directory: fileSystemMocks.Directory, + File: fileSystemMocks.File, + Paths: { document: "/documents" }, +})); + +import { IncomingShareStorageError, loadIncomingShareDrafts } from "./incoming-share-storage"; + +const VALID_DRAFT = { + schemaVersion: 1, + id: "share-valid", + createdAt: "2026-08-28T12:00:00.000Z", + text: "Review this file", + attachments: [], + warnings: [], +} as const; + +afterEach(() => { + fileSystemMocks.setEntries([]); + vi.restoreAllMocks(); +}); + +describe("incoming share storage", () => { + it("skips an invalid persisted share by default", async () => { + fileSystemMocks.setEntries([ + new fileSystemMocks.File("valid.json", JSON.stringify(VALID_DRAFT)), + new fileSystemMocks.File("invalid.json", "{"), + ]); + const warning = vi.spyOn(console, "warn").mockImplementation(() => undefined); + + await expect(loadIncomingShareDrafts()).resolves.toEqual([VALID_DRAFT]); + expect(warning).toHaveBeenCalledOnce(); + }); + + it("rejects an invalid persisted share in strict mode", async () => { + fileSystemMocks.setEntries([new fileSystemMocks.File("invalid.json", "{")]); + + await expect(loadIncomingShareDrafts({ strict: true })).rejects.toBeInstanceOf( + IncomingShareStorageError, + ); + }); +}); diff --git a/apps/mobile/src/features/sharing/incoming-share-storage.ts b/apps/mobile/src/features/sharing/incoming-share-storage.ts index 8364b4c98a45..cc3ffda02017 100644 --- a/apps/mobile/src/features/sharing/incoming-share-storage.ts +++ b/apps/mobile/src/features/sharing/incoming-share-storage.ts @@ -33,7 +33,9 @@ async function getFile(shareId: string) { return new File(await getDirectory(), fileName(shareId)); } -export async function loadIncomingShareDrafts(): Promise> { +export async function loadIncomingShareDrafts(options?: { + readonly strict?: boolean; +}): Promise> { try { const { File } = await import("expo-file-system"); const drafts: IncomingShareDraft[] = []; @@ -44,14 +46,18 @@ export async function loadIncomingShareDrafts(): Promise right.createdAt.localeCompare(left.createdAt)); } catch (cause) { + if (cause instanceof IncomingShareStorageError) { + throw cause; + } throw new IncomingShareStorageError({ operation: "load", shareId: null, cause }); } } diff --git a/apps/mobile/src/features/terminal/ThreadTerminalPanel.tsx b/apps/mobile/src/features/terminal/ThreadTerminalPanel.tsx deleted file mode 100644 index b6466abc2710..000000000000 --- a/apps/mobile/src/features/terminal/ThreadTerminalPanel.tsx +++ /dev/null @@ -1,253 +0,0 @@ -import { DEFAULT_TERMINAL_ID, type EnvironmentId, type ThreadId } from "@t3tools/contracts"; -import { SymbolView } from "../../components/AppSymbol"; -import { memo, useCallback, useEffect, useMemo, useRef } from "react"; -import { Pressable, View } from "react-native"; - -import { AppText as Text } from "../../components/AppText"; -import { useThemeColor } from "../../lib/useThemeColor"; -import { terminalEnvironment } from "../../state/terminal"; -import { useAtomCommand } from "../../state/use-atom-command"; -import { useAttachedTerminalSession } from "../../state/use-terminal-session"; -import { TerminalSurface } from "./NativeTerminalSurface"; -import { hasNativeTerminalSurface } from "./nativeTerminalModule"; -import { - buildThreadTerminalAttachInput, - type TerminalGridSize, - type ThreadTerminalSubscriptionIdentity, -} from "./threadTerminalPanelModel"; - -interface ThreadTerminalPanelProps { - readonly environmentId: EnvironmentId; - readonly threadId: ThreadId; - readonly cwd: string; - readonly worktreePath: string | null; - readonly visible: boolean; - readonly onClose: () => void; -} - -const DEFAULT_TERMINAL_COLS = 80; -const DEFAULT_TERMINAL_ROWS = 24; - -export const ThreadTerminalPanel = memo(function ThreadTerminalPanel( - props: ThreadTerminalPanelProps, -) { - const writeTerminal = useAtomCommand(terminalEnvironment.write, "terminal write"); - const resizeTerminal = useAtomCommand(terminalEnvironment.resize, "terminal resize"); - const closeTerminal = useAtomCommand(terminalEnvironment.close, "terminal close"); - const openTerminal = useAtomCommand(terminalEnvironment.open, "terminal open"); - const nativeTerminalAvailable = hasNativeTerminalSurface(); - const iconColor = useThemeColor("--color-icon"); - const terminalId = DEFAULT_TERMINAL_ID; - const lastGridSizeRef = useRef({ - cols: DEFAULT_TERMINAL_COLS, - rows: DEFAULT_TERMINAL_ROWS, - }); - const subscriptionIdentity = useMemo( - () => ({ - environmentId: props.environmentId, - threadId: props.threadId, - terminalId, - cwd: props.cwd, - worktreePath: props.worktreePath, - }), - [props.cwd, props.environmentId, props.threadId, props.worktreePath, terminalId], - ); - const attachInput = useMemo( - () => - props.visible - ? buildThreadTerminalAttachInput(subscriptionIdentity, lastGridSizeRef.current) - : null, - [props.visible, subscriptionIdentity], - ); - const terminal = useAttachedTerminalSession({ - environmentId: props.environmentId, - terminal: attachInput, - }); - - const terminalKey = `${props.environmentId}:${props.threadId}:${terminalId}`; - const isRunning = terminal.status === "running" || terminal.status === "starting"; - - // Close the session and dismiss the panel when the process ends while - // attached (e.g. typing `exit`), mirroring the web drawer's - // onSessionExited flow. - const runningTerminalKeyRef = useRef(null); - const reopenedStaleTerminalKeyRef = useRef(null); - - // Attach subscriptions are cached with an idle TTL; reopening the panel - // after its session ended reuses the stale stream without a new attach - // RPC. Issue an explicit open so the server respawns the session and its - // snapshot flows into the live subscription. - useEffect(() => { - if (isRunning) { - reopenedStaleTerminalKeyRef.current = null; - return; - } - if ( - attachInput === null || - (terminal.status !== "closed" && terminal.status !== "exited") || - terminal.version === 0 || - runningTerminalKeyRef.current === terminalKey || - reopenedStaleTerminalKeyRef.current === terminalKey - ) { - return; - } - reopenedStaleTerminalKeyRef.current = terminalKey; - void openTerminal({ - environmentId: props.environmentId, - input: { - threadId: props.threadId, - terminalId, - cwd: props.cwd, - worktreePath: props.worktreePath, - cols: lastGridSizeRef.current.cols, - rows: lastGridSizeRef.current.rows, - }, - }).then((result) => { - // Release the guard on failure so a later render can retry the respawn. - if (result._tag === "Failure" && reopenedStaleTerminalKeyRef.current === terminalKey) { - reopenedStaleTerminalKeyRef.current = null; - } - }); - }, [ - attachInput, - isRunning, - openTerminal, - props.cwd, - props.environmentId, - props.threadId, - props.worktreePath, - terminal.status, - terminal.version, - terminalId, - terminalKey, - ]); - - useEffect(() => { - // Forget both markers while hidden: if the process ends while the panel - // is unobserved (or was just auto-closed), the next show must take the - // stale-reopen path instead of treating it as a live exit or skipping - // the respawn. - if (attachInput === null) { - runningTerminalKeyRef.current = null; - reopenedStaleTerminalKeyRef.current = null; - return; - } - if (isRunning) { - runningTerminalKeyRef.current = terminalKey; - return; - } - // The web drawer treats both exited and closed as session end. - const sessionEnded = terminal.status === "exited" || terminal.status === "closed"; - if (!sessionEnded || runningTerminalKeyRef.current !== terminalKey) { - return; - } - runningTerminalKeyRef.current = null; - // Mark this key handled so the stale-attach effect doesn't respawn the - // session the user just ended. - reopenedStaleTerminalKeyRef.current = terminalKey; - void closeTerminal({ - environmentId: props.environmentId, - input: { - threadId: props.threadId, - terminalId, - }, - }); - props.onClose(); - }, [attachInput, closeTerminal, isRunning, props, terminal.status, terminalId, terminalKey]); - - const sendResize = useCallback( - (size: TerminalGridSize) => { - void resizeTerminal({ - environmentId: props.environmentId, - input: { - threadId: props.threadId, - terminalId, - cols: size.cols, - rows: size.rows, - }, - }); - }, - [props.environmentId, props.threadId, resizeTerminal, terminalId], - ); - - useEffect(() => { - if (isRunning) { - sendResize(lastGridSizeRef.current); - } - }, [isRunning, sendResize]); - - const handleInput = useCallback( - (data: string) => { - if (!isRunning) { - return; - } - - void writeTerminal({ - environmentId: props.environmentId, - input: { - threadId: props.threadId, - terminalId, - data, - }, - }); - }, - [isRunning, props.environmentId, props.threadId, terminalId, writeTerminal], - ); - - const handleResize = useCallback( - (size: TerminalGridSize) => { - const previousSize = lastGridSizeRef.current; - if (size.cols === previousSize.cols && size.rows === previousSize.rows) { - return; - } - - lastGridSizeRef.current = size; - if (!isRunning) { - return; - } - - sendResize(size); - }, - [isRunning, sendResize], - ); - - if (!props.visible) { - return null; - } - - return ( - - - - - Terminal - - - {nativeTerminalAvailable ? "Native Ghostty surface" : "Text fallback active"} - - - - {terminal.error ? ( - - {terminal.error} - - ) : null} - - - - - - - - ); -}); diff --git a/apps/mobile/src/features/terminal/threadTerminalPanelModel.ts b/apps/mobile/src/features/terminal/threadTerminalPanelModel.ts deleted file mode 100644 index 07ef46a7bc6d..000000000000 --- a/apps/mobile/src/features/terminal/threadTerminalPanelModel.ts +++ /dev/null @@ -1,28 +0,0 @@ -import type { EnvironmentId, TerminalAttachInput } from "@t3tools/contracts"; - -export interface ThreadTerminalSubscriptionIdentity { - readonly environmentId: EnvironmentId; - readonly threadId: TerminalAttachInput["threadId"]; - readonly terminalId: TerminalAttachInput["terminalId"]; - readonly cwd: string; - readonly worktreePath: string | null; -} - -export interface TerminalGridSize { - readonly cols: number; - readonly rows: number; -} - -export function buildThreadTerminalAttachInput( - identity: ThreadTerminalSubscriptionIdentity, - gridSize: TerminalGridSize, -): TerminalAttachInput { - return { - threadId: identity.threadId, - terminalId: identity.terminalId, - cwd: identity.cwd, - worktreePath: identity.worktreePath, - cols: gridSize.cols, - rows: gridSize.rows, - }; -} diff --git a/apps/mobile/src/features/threads/ComposerCommandPopover.tsx b/apps/mobile/src/features/threads/ComposerCommandPopover.tsx index cbc47c99c2e3..7ecb9f64137d 100644 --- a/apps/mobile/src/features/threads/ComposerCommandPopover.tsx +++ b/apps/mobile/src/features/threads/ComposerCommandPopover.tsx @@ -5,13 +5,12 @@ import { import type { ServerProviderSkill, ServerProviderSlashCommand } from "@t3tools/contracts"; import type { ComposerTriggerKind } from "@t3tools/shared/composerTrigger"; import { memo } from "react"; -import { Pressable, ScrollView, View, type ViewStyle } from "react-native"; +import { Pressable, ScrollView, StyleSheet, View, type ViewStyle } from "react-native"; import { SymbolView, type AppSymbolName } from "../../components/AppSymbol"; import { AppText as Text } from "../../components/AppText"; import { GlassSurface } from "../../components/GlassSurface"; import { PierreEntryIcon } from "../../components/PierreEntryIcon"; -import { useThemeColor } from "../../lib/useThemeColor"; export type ComposerCommandItem = | { readonly id: string; @@ -51,7 +50,6 @@ interface ComposerCommandPopoverProps { } function PopoverSurface(props: { readonly children: React.ReactNode; readonly style?: ViewStyle }) { - const tintColor = useThemeColor("--color-glass-surface"); const baseStyle: ViewStyle = { borderRadius: 16, overflow: "hidden", @@ -59,7 +57,11 @@ function PopoverSurface(props: { readonly children: React.ReactNode; readonly st }; return ( - + {props.children} ); @@ -122,27 +124,23 @@ const CommandRow = memo(function CommandRow(props: { readonly isSlashSkill: boolean; }) { const iconName = itemIcon(props.item); - const iconColor = useThemeColor("--color-icon-subtle"); - const borderColor = useThemeColor("--color-border"); return ( ({ - flexDirection: "row", - alignItems: "center", - paddingHorizontal: 14, - paddingVertical: 10, - gap: 10, - opacity: pressed ? 0.6 : 1, - borderBottomWidth: props.isLast ? 0 : 0.5, - borderBottomColor: borderColor, - })} + className="flex-row items-center gap-2.5 border-border px-3.5 py-2.5 active:opacity-60" + style={{ borderBottomWidth: props.isLast ? 0 : StyleSheet.hairlineWidth }} > {props.item.type === "path" ? ( ) : iconName ? ( - + ) : null} {props.isSlashSkill && props.item.type === "skill" ? ( diff --git a/apps/mobile/src/features/threads/GitActionProgressOverlay.tsx b/apps/mobile/src/features/threads/GitActionProgressOverlay.tsx index 8aeadc95cb6c..fb12a35d6c23 100644 --- a/apps/mobile/src/features/threads/GitActionProgressOverlay.tsx +++ b/apps/mobile/src/features/threads/GitActionProgressOverlay.tsx @@ -1,21 +1,22 @@ import * as Haptics from "expo-haptics"; -import { isLiquidGlassSupported, LiquidGlassView } from "@callstack/liquid-glass"; +import { GlassView } from "expo-glass-effect"; import { SymbolView } from "../../components/AppSymbol"; import { useCallback, useEffect, useRef } from "react"; -import { ActivityIndicator, Pressable, StyleSheet, View } from "react-native"; +import { ActivityIndicator, Pressable, StyleSheet, useColorScheme, View } from "react-native"; import Animated, { FadeIn, FadeOut, LinearTransition } from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AppText as Text } from "../../components/AppText"; import { APP_BAR_HEIGHT } from "../../lib/layoutMetrics"; +import { themeColorWithAlpha } from "../../lib/mobileTheme"; import { tryOpenExternalUrl } from "../../lib/openExternalUrl"; -import { useThemeColor } from "../../lib/useThemeColor"; +import { useUniwindTheme } from "../../lib/useUniwindTheme"; +import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; import type { GitActionProgress } from "../../state/use-vcs-action-state"; -import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; const OVERLAY_LAYOUT_TRANSITION = LinearTransition.duration(220); const OVERLAY_TOP_GAP = 8; -const AnimatedLiquidGlassView = Animated.createAnimatedComponent(LiquidGlassView); +const AnimatedGlassView = Animated.createAnimatedComponent(GlassView); export function GitActionProgressOverlay(props: { readonly progress: GitActionProgress; @@ -52,7 +53,7 @@ export function GitActionProgressOverlay(props: { return ( - + {progress.label ? ( @@ -90,32 +93,41 @@ function OverlayContent(props: { readonly progress: GitActionProgress }) { {progress.prUrl ? ( - + ) : null} ); - if (isLiquidGlassSupported) { + if (NATIVE_LIQUID_GLASS_SUPPORTED) { return ( - @@ -125,14 +137,14 @@ function OverlayContent(props: { readonly progress: GitActionProgress }) { > {content} - + ); } const bgClass = progress.phase === "error" - ? "bg-red-50 dark:bg-red-950/80 border-red-200 dark:border-red-800" + ? "border-adaptive-red-200-800 bg-adaptive-red-50-950-a80" : "bg-card border-border"; return ( @@ -145,13 +157,10 @@ function OverlayContent(props: { readonly progress: GitActionProgress }) { ); } -function OverlayIcon(props: { - readonly phase: GitActionProgress["phase"]; - readonly iconColor: ReturnType; -}) { +function OverlayIcon(props: { readonly phase: GitActionProgress["phase"] }) { switch (props.phase) { case "running": - return ; + return ; case "success": return ( diff --git a/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx b/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx index 97bb2ab98291..411598db08d7 100644 --- a/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx +++ b/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx @@ -24,7 +24,7 @@ import { AppText as Text } from "../../components/AppText"; import { ThemedSwitch } from "../../components/ThemedSwitch"; import { cn } from "../../lib/cn"; import { useFontFamily } from "../../lib/useFontFamily"; -import { useThemeColor } from "../../lib/useThemeColor"; +import { useUniwindTheme } from "../../lib/useUniwindTheme"; import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; import { useAtomCommand } from "../../state/use-atom-command"; import { vcsEnvironment } from "../../state/vcs"; @@ -45,9 +45,6 @@ function SelectionRow(props: { readonly subtitle?: string; readonly title: string; }) { - const iconColor = useThemeColor("--color-icon-muted"); - const checkmarkColor = useThemeColor("--color-icon"); - return ( {props.icon ? ( - + ) : null} @@ -78,7 +80,7 @@ function SelectionRow(props: { @@ -191,8 +193,7 @@ export function NewTaskBranchPickerRouteScreen() { const flow = useNewTaskFlow(); const navigation = useNavigation(); const insets = useSafeAreaInsets(); - const placeholderColor = useThemeColor("--color-placeholder"); - const foregroundColor = useThemeColor("--color-foreground"); + const foregroundColor = useUniwindTheme()["--color-foreground"]; const fontFamily = useFontFamily("regular"); const switchRef = useAtomCommand(vcsEnvironment.switchRef, { reportFailure: false }); const [switchingBranchName, setSwitchingBranchName] = useState(null); @@ -419,7 +420,7 @@ export function NewTaskBranchPickerRouteScreen() { className="h-11 rounded-xl bg-card px-4 text-base text-foreground" onChangeText={flow.setBranchQuery} placeholder="Find a branch" - placeholderTextColor={placeholderColor} + placeholderTextColorClassName={"accent-placeholder"} style={{ color: foregroundColor, fontFamily }} value={flow.branchQuery} /> diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index 8f5beb69c938..8f8cf485f6f8 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -1,9 +1,12 @@ +import { useAtomValue } from "@effect/atom-react"; import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; import { + CommonActions, StackActions, useFocusEffect, useNavigation, usePreventRemove, + type NavigationAction, } from "@react-navigation/native"; import { useCallback, useEffect, useRef, useState } from "react"; import { Alert, Platform, Pressable, ScrollView, View } from "react-native"; @@ -12,50 +15,76 @@ import { KeyboardStickyView, useKeyboardState, } from "react-native-keyboard-controller"; +import Animated from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { useThemeColor } from "../../lib/useThemeColor"; -import { themeColorWithAlpha } from "../../lib/mobileTheme"; +import { useUniwindTheme } from "../../lib/useUniwindTheme"; import { useFontFamily } from "../../lib/useFontFamily"; import { isAtomCommandInterrupted, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; +import { PROVIDER_SEND_TURN_MAX_ATTACHMENTS } from "@t3tools/contracts"; import { ComposerEditor, type ComposerEditorHandle } from "../../components/ComposerEditor"; import { + ComposerActionButton, ComposerInlineControl, - ComposerToolbarButton, ComposerToolbarRow, ComposerToolbarScroller, } from "../../components/ComposerToolbar"; import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; +import { ComposerAttachmentButton } from "../../components/ComposerAttachmentButton"; import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip"; +import { + composerAttachmentUploadBlockReason, + composerAttachmentUploadsAtom, +} from "../../state/composer-attachment-uploads"; +import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal"; +import { VideoPreviewModal, type VideoPreviewSource } from "../../components/VideoPreviewModal"; import { ProviderIcon } from "../../components/ProviderIcon"; import { SymbolView } from "../../components/AppSymbol"; import { AppText as Text } from "../../components/AppText"; -import { ComposerSurface } from "./ThreadComposer"; +import { COMPOSER_LAYOUT_TRANSITION, ComposerSurface } from "./ThreadComposer"; +import { ShimmeringWorkContent } from "./thread-work-log"; +import { ComposerCommandPopover } from "./ComposerCommandPopover"; +import { useComposerCommandMenu } from "./use-composer-command-menu"; +import { + ComposerDictationCancelAction, + ComposerDictationPrimaryAction, + ComposerDictationStatus, + ComposerDictationToolbar, +} from "../voice-input/ComposerDictationControl"; +import { useVoiceInputController } from "../voice-input/useVoiceInputController"; +import { resolveVoiceComposerPresentation } from "../voice-input/voiceInputPresentation"; import { useThreadSettingsSheetPresentation, type NavigationWithFinishTransitioning, } from "./use-thread-settings-sheet-presentation"; import { makeTurnCommandMetadata } from "../../lib/commandMetadata"; -import { convertPastedImagesToAttachments, pickComposerImages } from "../../lib/composerImages"; +import { + convertPastedImagesToAttachments, + pickComposerFiles, + pickComposerMedia, + type DraftComposerFileAttachment, +} from "../../lib/composerImages"; import { useScaledTextRole } from "../settings/appearance/useScaledTextRole"; -import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { clearComposerDraftContent, + flushComposerDrafts, getComposerDraftSnapshot, mergeComposerDraftContent, restoreComposerDraftSnapshot, + scheduleUnusedComposerAttachmentCleanup, type ComposerDraft, } from "../../state/use-composer-drafts"; import { useEnvironmentServerConfig, useProjects } from "../../state/entities"; import { resolveSelectableModelSelection } from "../../lib/modelOptions"; import { deriveThreadTitleFromPrompt } from "../../lib/projectThreadStartTurn"; import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration"; -import { enqueueThreadOutboxMessage, removeThreadOutboxMessage } from "../../state/thread-outbox"; +import { enqueueThreadOutboxMessage } from "../../state/thread-outbox"; +import { removeThreadOutboxMessage } from "../../state/thread-outbox-removal"; import { useRemoteConnectionStatus } from "../../state/use-remote-environment-registry"; import { useNewTaskFlow } from "./new-task-flow-provider"; import { resolveProjectThreadCreationBranch } from "./projectThreadCreationValidation"; @@ -66,22 +95,40 @@ import { resolveNewTaskWorkspaceLabel, } from "./new-task-context-presentation"; import { useIncomingShare } from "../sharing/IncomingShareProvider"; +import { selectIncomingShareAttachmentsForServer } from "../sharing/incoming-share-model"; +import { appAtomRegistry } from "../../state/atom-registry"; +import { serverEnvironment } from "../../state/server"; function NewTaskWorkspaceIcon(props: { readonly workspaceMode: "local" | "worktree"; readonly worktreePath: string | null; }) { - const iconColor = useThemeColor("--color-icon-muted"); - if (props.workspaceMode === "local" && props.worktreePath === null) { - return ; + return ( + + ); } return ( - + - + ); @@ -109,7 +156,6 @@ export function NewTaskDraftScreen(props: { reserveShare, } = useIncomingShare(); const insets = useSafeAreaInsets(); - const { themeAppearance: colorScheme } = useAppearancePreferences(); const isKeyboardVisible = useKeyboardState((state) => state.isVisible); const controlsBottomPadding = Math.max(insets.bottom, 10); const keyboardOpenedOffset = Math.max(0, controlsBottomPadding - 8); @@ -123,9 +169,47 @@ export function NewTaskDraftScreen(props: { connectedEnvironments.find( (environment) => environment.environmentId === selectedProject.environmentId, )?.connectionState === "connected"; + const uploadStates = useAtomValue(composerAttachmentUploadsAtom); + const attachmentBlockReason = selectedProject + ? composerAttachmentUploadBlockReason({ + environmentId: selectedProject.environmentId, + attachments: flow.attachments, + connected: environmentConnected, + serverConfig: selectedEnvironmentServerConfig, + states: uploadStates, + }) + : null; const promptInputRef = useRef(null); const loadedBranchesProjectKeyRef = useRef(null); const [isComposerFocused, setIsComposerFocused] = useState(false); + const [previewVideo, setPreviewVideo] = useState(null); + const [previewFile, setPreviewFile] = useState(null); + const wasFocusedBeforePreviewRef = useRef(false); + const openVideoPreview = useCallback( + (attachment: DraftComposerFileAttachment, sourceIdentifier: string) => { + wasFocusedBeforePreviewRef.current = isComposerFocused; + setPreviewFile(null); + setPreviewVideo((current) => current ?? { type: "local", attachment, sourceIdentifier }); + }, + [isComposerFocused], + ); + const openFilePreview = useCallback( + (source: FilePreviewSource) => { + wasFocusedBeforePreviewRef.current = isComposerFocused; + setPreviewVideo(null); + setPreviewFile((current) => current ?? source); + }, + [isComposerFocused], + ); + const closeMediaPreview = useCallback(() => { + setPreviewVideo(null); + setPreviewFile(null); + if (wasFocusedBeforePreviewRef.current) { + setTimeout(() => { + if (navigation.isFocused()) promptInputRef.current?.focus(); + }, 100); + } + }, [navigation]); const settingsSheetPresentation = useThreadSettingsSheetPresentation({ editorRef: promptInputRef, isEditorFocused: isComposerFocused, @@ -177,6 +261,9 @@ export function NewTaskDraftScreen(props: { const [isCancellingShareImport, setIsCancellingShareImport] = useState(false); const [cancelledIncomingShareId, setCancelledIncomingShareId] = useState(null); const [isReturningToProjectPicker, setIsReturningToProjectPicker] = useState(false); + const [submitNavigationAction, setSubmitNavigationAction] = useState( + null, + ); const [shareImportAttempt, setShareImportAttempt] = useState(0); const startedShareImportKeyRef = useRef(null); const cancellingShareImportKeyRef = useRef(null); @@ -201,13 +288,62 @@ export function NewTaskDraftScreen(props: { ); const isProjectPickerReturnActive = isReturningToProjectPicker && !requestedInitialProjectAvailable; + const isIncomingShareAwaitingServerConfig = Boolean( + incomingShare?.attachments.some((attachment) => attachment.type === "file") && + selectedEnvironmentServerConfig === null, + ); const isIncomingShareTransferPending = Boolean( - incomingShare && cancelledIncomingShareId !== props.incomingShareId, + incomingShare && + cancelledIncomingShareId !== props.incomingShareId && + !isIncomingShareAwaitingServerConfig, ); - usePreventRemove( - (isIncomingShareTransferPending && !isProjectPickerReturnActive) || isCancellingShareImport, - () => undefined, + const isComposerInteractionLocked = isIncomingShareTransferPending || flow.submitting; + // Also guard while a submit is in flight: an Android back press or iOS + // Cancel would otherwise abandon the screen while the task still starts. + const composerMenu = useComposerCommandMenu({ + draftMessage: flow.prompt, + ownerKey: flow.draftKey, + environmentId: selectedProject?.environmentId ?? null, + projectCwd: + (flow.workspaceMode === "worktree" + ? selectedProject?.workspaceRoot + : (flow.selectedWorktreePath ?? selectedProject?.workspaceRoot)) || null, + selectedProviderStatus: flow.selectedProviderStatus, + hasThread: false, + enabled: isComposerFocused && !isComposerInteractionLocked, + onChangeDraftMessage: flow.setPrompt, + onUpdateInteractionMode: flow.planModeEnabled ? flow.setInteractionMode : undefined, + }); + const voiceInput = useVoiceInputController({ + ownerKey: flow.draftKey, + draftMessage: flow.prompt, + selection: composerMenu.selection, + disabled: isIncomingShareTransferPending || isImportingShare || flow.submitting, + onChangeDraftMessage: flow.setPrompt, + onChangeSelection: composerMenu.onSelectionChange, + }); + const voicePresentation = resolveVoiceComposerPresentation( + voiceInput.state, + voiceInput.elapsedSeconds, ); + const isVoiceInputPresented = voicePresentation.statusLabel !== null; + const preventRemove = + (isIncomingShareTransferPending && !isProjectPickerReturnActive) || + isCancellingShareImport || + flow.submitting; + usePreventRemove(preventRemove, () => undefined); + useEffect(() => { + if (preventRemove || submitNavigationAction === null) { + return; + } + // Give the guard update a frame to reach the parent sheet before navigating, + // just like the project-picker fallback below. + const frame = requestAnimationFrame(() => { + setSubmitNavigationAction(null); + (navigation.getParent() ?? navigation).dispatch(submitNavigationAction); + }); + return () => cancelAnimationFrame(frame); + }, [navigation, preventRemove, submitNavigationAction]); const hasImportedIncomingShare = Boolean( props.incomingShareId && flow.draftKey && @@ -291,13 +427,10 @@ export function NewTaskDraftScreen(props: { }; }, [props.pendingTaskId, cancelEditingPendingTask]); - const foregroundColor = useThemeColor("--color-foreground"); - const sheetColor = String(useThemeColor("--color-sheet")); - const projectUnderlineColor = useThemeColor("--color-foreground-muted"); + const theme = useUniwindTheme(); + const foregroundColor = theme["--color-foreground"]; const regularFontFamily = useFontFamily("regular"); const bodyText = useScaledTextRole("body"); - const sheetFadeOpaque = sheetColor; - const sheetFadeTransparent = themeColorWithAlpha(sheetColor, 0); // A new navigation to this mounted screen delivers a fresh initialProjectRef // reference — treat it as a new request and let it apply again. @@ -423,6 +556,13 @@ export function NewTaskDraftScreen(props: { return; } + if ( + incomingShare.attachments.some((attachment) => attachment.type === "file") && + selectedEnvironmentServerConfig === null + ) { + return; + } + if (alertedUnavailableIncomingShareIdRef.current === shareId) { alertedUnavailableIncomingShareIdRef.current = null; } @@ -432,6 +572,7 @@ export function NewTaskDraftScreen(props: { shareImportDraftBackupRef.current.set(importKey, draftBackup); const importToken = Symbol(importKey); let didReserveShare = false; + let didConsumeShare = false; let needsDraftRestore = false; activeShareImportTokenRef.current = importToken; setImportingShareKey(importKey); @@ -449,10 +590,19 @@ export function NewTaskDraftScreen(props: { ) { return; } + const selectedAttachments = selectIncomingShareAttachmentsForServer({ + attachments: incomingShare.attachments, + serverConfig: appAtomRegistry.get( + serverEnvironment.configValueAtom(destinationProject.environmentId), + ), + }); + if (selectedAttachments.status === "pending") { + throw new Error("Server attachment support is still loading."); + } needsDraftRestore = true; const { skippedAttachmentCount } = await mergeComposerDraftContent(draftKey, { text: incomingShare.text, - attachments: incomingShare.attachments, + attachments: selectedAttachments.attachments, sourceShareId: shareId, }); if ( @@ -466,13 +616,25 @@ export function NewTaskDraftScreen(props: { return; } await consumeShare(shareId); + didConsumeShare = true; + // The consumed inbox draft was the last owner of files that never made + // it into the composer draft (unsupported server, oversize, limit + // skips). Release them before any early return: an unmount or a + // superseding import must not leak them, and the sweep re-checks + // ownership so it cannot delete a file another draft picked up. + const retainedAttachmentIds = new Set( + getComposerDraftSnapshot(draftKey).attachments.map((attachment) => attachment.id), + ); + scheduleUnusedComposerAttachmentCleanup( + incomingShare.attachments.filter((attachment) => !retainedAttachmentIds.has(attachment.id)), + ); if (!shareImportMountedRef.current || activeShareImportTokenRef.current !== importToken) { return; } - const warnings = [...incomingShare.warnings]; + const warnings = [...incomingShare.warnings, ...selectedAttachments.warnings]; if (skippedAttachmentCount > 0) { warnings.push( - `${skippedAttachmentCount} shared image${skippedAttachmentCount === 1 ? " was" : "s were"} skipped because this draft reached the attachment limit.`, + `${skippedAttachmentCount} shared file${skippedAttachmentCount === 1 ? " was" : "s were"} skipped because this draft reached the attachment limit.`, ); } if (warnings.length > 0) { @@ -503,8 +665,16 @@ export function NewTaskDraftScreen(props: { setIsCancellingShareImport(true); try { if (needsDraftRestore) { + // The restore drops the share's merged-in attachments + // from the draft. Sweep them only when the inbox entry + // was consumed: before that, the inbox still references + // these files and must keep them for a later import. + const mergedAttachments = getComposerDraftSnapshot(draftKey).attachments; await restoreComposerDraftSnapshot(draftKey, draftBackup); needsDraftRestore = false; + if (didConsumeShare) { + scheduleUnusedComposerAttachmentCleanup(mergedAttachments); + } } if (didReserveShare) { await releaseShareReservation(shareId, { @@ -579,6 +749,7 @@ export function NewTaskDraftScreen(props: { props.initialProjectRef?.projectId, releaseShareReservation, reserveShare, + selectedEnvironmentServerConfig, selectedProject, shareImportAttempt, ]); @@ -609,13 +780,56 @@ export function NewTaskDraftScreen(props: { }); const showBranchLoading = flow.branchesLoading && flow.availableBranches.length === 0; - async function handlePickImages(): Promise { - if (isIncomingShareTransferPending) { + async function handlePickMedia(): Promise { + if (isComposerInteractionLocked || voiceInput.isBusy) { return; } - const result = await pickComposerImages({ existingCount: flow.attachments.length }); - if (result.images.length > 0) { - flow.appendAttachments(result.images); + const capabilities = selectedEnvironmentServerConfig?.environment.capabilities; + const result = await pickComposerMedia({ + existingCount: flow.attachments.length, + maxVideoBytes: + capabilities?.attachmentUploads === true + ? capabilities.fileAttachments?.maxUploadBytes + : undefined, + }); + const rejectedCount = + result.attachments.length > 0 ? flow.appendAttachments(result.attachments) : 0; + const problems = [ + ...(result.error ? [result.error] : []), + ...(rejectedCount > 0 + ? [`You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} attachments per message.`] + : []), + ]; + if (problems.length > 0) { + Alert.alert("Could not attach photo or video", problems.join("\n\n")); + } + } + + async function handlePickFiles(): Promise { + if (isComposerInteractionLocked || voiceInput.isBusy) { + return; + } + const maxBytes = + selectedEnvironmentServerConfig?.environment.capabilities.fileAttachments?.maxUploadBytes; + if (maxBytes === undefined) { + Alert.alert("File attachments are not available on this server."); + return; + } + const result = await pickComposerFiles({ + existingCount: flow.attachments.length, + maxBytes, + }); + const rejectedCount = result.files.length > 0 ? flow.appendAttachments(result.files) : 0; + // The picker error and the live-cap rejection can both happen in one + // pick; report both in a single alert. + const problems = [ + ...(result.error ? [result.error] : []), + ...(rejectedCount > 0 + ? [`You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} files per message.`] + : []), + ]; + if (problems.length > 0) { + Alert.alert("Could not attach file", problems.join("\n\n")); } } @@ -637,6 +851,7 @@ export function NewTaskDraftScreen(props: { ); async function handleStart(): Promise { + if (voiceInput.blocksSubmission) return; const selectedProject = flow.selectedProject; const draftKey = flow.draftKey; if (!selectedProject || !draftKey) { @@ -663,6 +878,7 @@ export function NewTaskDraftScreen(props: { const initialMessageText = draft.text.trim(); if ( + attachmentBlockReason !== null || !modelSelection || initialMessageText.length === 0 || flow.submitting || @@ -670,6 +886,16 @@ export function NewTaskDraftScreen(props: { ) { return; } + // A failed-send restore can leave the draft over the cap on purpose (it + // never drops the user's files); starting anyway would upload everything + // and have the server reject the turn. + if (draft.attachments.length > PROVIDER_SEND_TURN_MAX_ATTACHMENTS) { + Alert.alert( + "Too many attachments", + `Remove attachments until there are at most ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS}.`, + ); + return; + } const editingPendingTask = flow.editingPendingTask; @@ -704,12 +930,14 @@ export function NewTaskDraftScreen(props: { if (editingPendingTask) { flow.finishEditingPendingTask(); } else { - // Drop the workspace selection with the content: the next task should - // re-resolve mode/branch/origin from the server's configured defaults - // instead of resurrecting this task's picks. - clearComposerDraftContent(draftKey, { clearWorkspaceSelection: true }); + // Drop draft-local model/workspace selections with the content. The + // next task re-resolves project defaults before sticky app defaults. + clearComposerDraftContent(draftKey, { + clearModelSelection: true, + clearWorkspaceSelection: true, + }); } - navigation.getParent()?.goBack(); + setSubmitNavigationAction(CommonActions.goBack()); return; } @@ -739,6 +967,10 @@ export function NewTaskDraftScreen(props: { interactionMode, initialMessageText, initialAttachments: draft.attachments, + onAttachmentsUploaded: async (attachments) => { + flow.replaceAttachments(attachments); + await flushComposerDrafts(); + }, ...(editingPendingTask ? { turnMetadata: { @@ -771,9 +1003,12 @@ export function NewTaskDraftScreen(props: { } flow.finishEditingPendingTask(); } else { - clearComposerDraftContent(draftKey, { clearWorkspaceSelection: true }); + clearComposerDraftContent(draftKey, { + clearModelSelection: true, + clearWorkspaceSelection: true, + }); } - navigation.dispatch( + setSubmitNavigationAction( StackActions.replace("Thread", { environmentId: String(result.value.environmentId), threadId: String(result.value.threadId), @@ -797,14 +1032,15 @@ export function NewTaskDraftScreen(props: { } const isAndroid = Platform.OS === "android"; - const isDarkMode = colorScheme === "dark"; const canStart = + attachmentBlockReason === null && Boolean(flow.selectedProject) && Boolean(flow.selectedModel) && flow.prompt.trim().length > 0 && isIncomingShareReady && !isImportingShare && !flow.submitting && + !voiceInput.blocksSubmission && !(flow.workspaceMode === "worktree" && !flow.selectedBranchName); const promptEditor = ( setIsComposerFocused(true)} onBlur={() => setIsComposerFocused(false)} onPasteImages={(uris) => void handleNativePasteImages(uris)} @@ -827,7 +1066,6 @@ export function NewTaskDraftScreen(props: { style={{ minHeight: 72, maxHeight: 160, - paddingHorizontal: 4, paddingVertical: 4, }} textStyle={{ ...bodyText, color: foregroundColor, fontFamily: regularFontFamily }} @@ -844,7 +1082,7 @@ export function NewTaskDraftScreen(props: { navigation.goBack(); }; const chooseProject = () => { - if (isIncomingShareTransferPending) { + if (isComposerInteractionLocked) { return; } promptInputRef.current?.blur(); @@ -852,7 +1090,7 @@ export function NewTaskDraftScreen(props: { navigation.dispatch(StackActions.push("NewTask", { incomingShareId: props.incomingShareId })); }; const openContextPicker = (routeName: "NewTaskBranch" | "NewTaskEnvironment") => { - if (isIncomingShareTransferPending) { + if (isComposerInteractionLocked) { return; } promptInputRef.current?.blur(); @@ -872,13 +1110,9 @@ export function NewTaskDraftScreen(props: { accessibilityHint="Opens the project picker" accessibilityLabel={`Change project from ${selectedProject.title}`} accessibilityRole="button" - disabled={isIncomingShareTransferPending} + disabled={isComposerInteractionLocked} onPress={chooseProject} - className="min-w-0 max-w-[250px] active:opacity-65" - style={{ - borderBottomColor: projectUnderlineColor, - borderBottomWidth: 1, - }} + className="min-w-0 max-w-[250px] border-b border-foreground-muted active:opacity-65" > - + + + ) : ( + <> + + } + label={workspaceLabel} + maxWidth={flow.workspaceMode === "local" ? 220 : 148} + onPress={() => + flow.setWorkspaceMode(flow.workspaceMode === "local" ? "worktree" : "local") + } + showChevron={false} /> - } - label={workspaceLabel} - maxWidth={flow.workspaceMode === "local" ? 220 : 148} - onPress={() => flow.setWorkspaceMode(flow.workspaceMode === "local" ? "worktree" : "local")} - showChevron={false} - /> - openContextPicker("NewTaskBranch")} - /> + openContextPicker("NewTaskBranch")} + /> + + )} ); const composerDock = ( - + + {!voiceInput.isBusy && composerMenu.trigger && composerMenu.items.length > 0 ? ( + + + + ) : null} {workspaceControls} {flow.attachments.length > 0 ? ( - + undefined : flow.removeAttachment} + onRemove={ + isComposerInteractionLocked || voiceInput.isBusy + ? () => undefined + : flow.removeAttachment + } + onPressPreview={ + isComposerInteractionLocked || voiceInput.isBusy ? undefined : openFilePreview + } + onPressVideo={ + isComposerInteractionLocked || voiceInput.isBusy ? undefined : openVideoPreview + } /> ) : null} - {promptEditor} + {promptEditor} + - - - void handlePickImages()} - showChevron={false} - /> - - } - label={flow.selectedModelOption?.label ?? "Choose model"} - maxWidth={152} - onPress={settingsSheetPresentation.open} - /> - {flow.planModeEnabled ? ( - - flow.setInteractionMode(flow.interactionMode === "plan" ? "default" : "plan") - } - showChevron={false} + + + + - ) : null} - - void handleStart()} - showChevron={false} - variant="primary" - /> - + {isVoiceInputPresented ? ( + + ) : ( + <> + + + + } + label={flow.selectedModelOption?.label ?? "Choose model"} + maxWidth={152} + onPress={settingsSheetPresentation.open} + /> + {flow.planModeEnabled ? ( + + flow.setInteractionMode( + flow.interactionMode === "plan" ? "default" : "plan", + ) + } + showChevron={false} + /> + ) : null} + + + )} + + {voicePresentation.showsSend ? ( + void handleStart()} + variant="primary" + /> + ) : null} + + + + + ); @@ -1077,10 +1393,17 @@ export function NewTaskDraftScreen(props: { {heroViewport} - {composerDock} + + {composerDock} + ); diff --git a/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx b/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx index 94304448eaf3..e9bcb1291e39 100644 --- a/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx @@ -10,7 +10,6 @@ import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; import { useEffect, useRef } from "react"; import { ActivityIndicator, Alert, Platform, Pressable, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { useThemeColor } from "../../lib/useThemeColor"; import { cn } from "../../lib/cn"; import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; @@ -91,8 +90,6 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps attachment.type === "image") ? "images" : "files"} you shared` : null; const screenTitle = incomingShare ? "Start a task" : "Choose project"; const projectEmptyState = deriveProjectEmptyState(catalogState); @@ -242,7 +239,9 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps {projectScopes.length === 0 ? ( - {projectEmptyState.loading ? : null} + {projectEmptyState.loading ? ( + + ) : null} {projectEmptyState.title} @@ -308,7 +307,7 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps diff --git a/apps/mobile/src/features/threads/PendingApprovalCard.tsx b/apps/mobile/src/features/threads/PendingApprovalCard.tsx index fb9cc72d25d3..0239cac1e041 100644 --- a/apps/mobile/src/features/threads/PendingApprovalCard.tsx +++ b/apps/mobile/src/features/threads/PendingApprovalCard.tsx @@ -28,15 +28,15 @@ export function PendingApprovalCard(props: PendingApprovalCardProps) { // Opaque for the same reason as PendingUserInputCard: nothing blurs the feed // behind this card, so a translucent surface bleeds messages through it. return ( - - + + Approval needed - + {props.approval.appName ?? props.approval.requestKind} {props.approval.detail ? ( - + {props.approval.detail} ) : null} @@ -48,8 +48,8 @@ export function PendingApprovalCard(props: PendingApprovalCardProps) { option.decision === "accept" ? "bg-blue-500" : option.decision === "decline" - ? "bg-rose-100 dark:bg-rose-500/18" - : "bg-neutral-200 dark:bg-neutral-800" + ? "bg-adaptive-rose-100-500-a18" + : "bg-adaptive-neutral-200-800" }`} disabled={props.respondingApprovalId === props.approval.requestId} onPress={() => void props.onRespond(props.approval.requestId, option.decision)} @@ -59,8 +59,8 @@ export function PendingApprovalCard(props: PendingApprovalCardProps) { option.decision === "accept" ? "font-t3-extrabold text-white" : option.decision === "decline" - ? "font-t3-bold text-rose-700 dark:text-rose-300" - : "font-t3-bold text-neutral-950 dark:text-neutral-50" + ? "font-t3-bold text-adaptive-rose-700-300" + : "font-t3-bold text-adaptive-neutral-950-50" }`} > {option.label} diff --git a/apps/mobile/src/features/threads/PendingUserInputCard.tsx b/apps/mobile/src/features/threads/PendingUserInputCard.tsx index 4b5a93cd1f75..5700d1b79e44 100644 --- a/apps/mobile/src/features/threads/PendingUserInputCard.tsx +++ b/apps/mobile/src/features/threads/PendingUserInputCard.tsx @@ -18,7 +18,6 @@ import { SymbolView } from "../../components/AppSymbol"; import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; import { ControlPill } from "../../components/ControlPill"; import { cn } from "../../lib/cn"; -import { useThemeColor } from "../../lib/useThemeColor"; import { isPendingUserInputOptionSelected, type PendingUserInput, @@ -87,7 +86,6 @@ const EXPANDED_CARD_IS_OVERLAY = Platform.OS === "ios"; const CARD_LAYOUT_TRANSITION = LinearTransition.duration(200); export function PendingUserInputCard(props: PendingUserInputCardProps) { - const iconSubtle = useThemeColor("--color-icon-subtle"); const questionCount = props.pendingUserInput.questions.length; const cardCoverage = props.cardCoverage; @@ -163,7 +161,7 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { pointerEvents={props.collapsed ? "auto" : "none"} accessibilityElementsHidden={!props.collapsed} importantForAccessibility={props.collapsed ? "auto" : "no-hide-descendants"} - className="flex-row items-center gap-2 rounded-full border border-neutral-200 bg-neutral-100 py-1.5 pl-4 pr-1.5 dark:border-white/6 dark:bg-neutral-900" + className="flex-row items-center gap-2 rounded-full border border-adaptive-neutral-200-white-a6 bg-adaptive-neutral-100-900 py-1.5 pl-4 pr-1.5" > - + User input needed - + {questionCount} question{questionCount === 1 ? "" : "s"} - + {props.onStopThread ? ( - + User input needed - + Fill in the pending answers - - + + - + {question.header} - + {question.question} @@ -268,8 +276,8 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { className={cn( "min-h-12 w-full rounded-2xl border px-3.5 py-3", selected - ? "border-blue-300/50 bg-blue-50 dark:border-blue-400/28 dark:bg-blue-400/14" - : "border-neutral-200 bg-white dark:border-white/6 dark:bg-neutral-950/70", + ? "border-adaptive-blue-300-a50-blue-400-a28 bg-adaptive-blue-50-blue-400-a14" + : "border-adaptive-neutral-200-white-a6 bg-adaptive-white-neutral-950-a70", )} onPress={() => props.onSelectOption( @@ -284,14 +292,14 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { className={cn( "font-t3-bold text-sm", selected - ? "text-sky-700 dark:text-sky-300" - : "text-neutral-700 dark:text-neutral-200", + ? "text-adaptive-sky-700-300" + : "text-adaptive-neutral-600-300", )} > {option.label} {description ? ( - + {description} ) : null} @@ -308,7 +316,7 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { onFocus={() => props.onInputFocusChange?.(true)} onBlur={() => props.onInputFocusChange?.(false)} placeholder="Or type a custom answer" - className="min-h-[54px] rounded-2xl border border-neutral-200 bg-white px-3.5 py-3 font-sans text-base text-neutral-950 dark:border-white/8 dark:bg-neutral-950/70 dark:text-neutral-50" + className="min-h-[54px] rounded-2xl border border-adaptive-neutral-200-white-a8 bg-adaptive-white-neutral-950-a70 px-3.5 py-3 font-sans text-base text-adaptive-neutral-950-50" /> ); @@ -317,7 +325,7 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { ; + readonly draftAttachments: ReadonlyArray; readonly placeholder: string; readonly contentMaxWidth?: number; readonly bottomInset?: number; @@ -111,7 +119,8 @@ export interface ThreadComposerProps { readonly projectCwd: string | null; readonly editorRef?: RefObject; readonly onChangeDraftMessage: (value: string) => void; - readonly onPickDraftImages: () => Promise; + readonly onPickDraftMedia: () => Promise; + readonly onPickDraftFiles: () => Promise; readonly onNativePasteImages: (uris: ReadonlyArray) => Promise; readonly onRemoveDraftImage: (imageId: string) => void; readonly onStopThread: () => void; @@ -130,56 +139,78 @@ export interface ThreadComposerProps { * iOS 26+ devices and keeps the existing opaque fallback elsewhere. * Exported so NewTaskDraftScreen can render the same composer chrome. */ -// One timing for every piece of the expanded↔compact morph so the surface, -// toolbar, and siblings move together instead of popping between layouts. +// The bottom-anchored dock position and clipped surface height use the same +// transition so the card grows upward without exposing its final-size content. // Android gets NO layout transition: the composer rides the keyboard via // KeyboardStickyView (frame-synced to the IME), and a time-based morph // running alongside that translate reads as jitter. Snapping the layout and // letting the keyboard-synced slide be the only motion looks native there. -const COMPOSER_LAYOUT_TRANSITION = - Platform.OS === "android" ? undefined : LinearTransition.duration(220); +export const COMPOSER_TRANSITION_DURATION_MS = 220; +export const COMPOSER_LAYOUT_TRANSITION = + Platform.OS === "android" + ? undefined + : LinearTransition.duration(COMPOSER_TRANSITION_DURATION_MS).reduceMotion(ReduceMotion.System); + +const AnimatedGlassSurface = Animated.createAnimatedComponent(GlassSurface); export function ComposerSurface(props: { readonly children: ReactNode; readonly style: ViewStyle; - readonly isDarkMode: boolean; - /** Existing thread composers morph between pill and card layouts. */ + /** Morphs between the compact and expanded composer layouts. */ readonly animateLayout?: boolean; }) { - const cardColor = useThemeColor("--color-card-translucent"); - const borderColor = useThemeColor("--color-border"); - const shadowColor = useThemeColor("--color-primary-shadow"); - // Drop shadow lives on a wrapper: `overflow: "hidden"` on the surface itself - // (needed to clip content to the pill shape) would clip the shadow on iOS. - const shadowStyle: ViewStyle = { - borderRadius: props.style.borderRadius, - shadowColor, - shadowOpacity: props.isDarkMode ? 0.35 : 0.12, - shadowRadius: 14, - shadowOffset: { width: 0, height: 6 }, - elevation: 10, - }; + const targetBorderRadius = + typeof props.style.borderRadius === "number" ? props.style.borderRadius : 0; + const animatedBorderRadius = useSharedValue(targetBorderRadius); + const shouldAnimate = props.animateLayout !== false && Platform.OS !== "android"; + useLayoutEffect(() => { + animatedBorderRadius.value = shouldAnimate + ? withTiming(targetBorderRadius, { + duration: COMPOSER_TRANSITION_DURATION_MS, + reduceMotion: ReduceMotion.System, + }) + : targetBorderRadius; + }, [animatedBorderRadius, shouldAnimate, targetBorderRadius]); + const animatedShapeStyle = useAnimatedStyle(() => ({ + borderRadius: animatedBorderRadius.value, + })); + const layoutTransition = shouldAnimate ? COMPOSER_LAYOUT_TRANSITION : undefined; + // Each native frame follows the same transition. Animating only the outer + // clip leaves the glass and content at their final height on the first frame. return ( - + {null} + + {props.children} - + ); } @@ -240,8 +271,6 @@ const ComposerConnectionStatusPill = memo(function ComposerConnectionStatusPill( readonly status: ComposerStatusPillState; }) { const isReconnecting = props.status.kind !== "unavailable"; - const indicatorColor = useThemeColor("--color-icon-muted"); - return ( {isReconnecting ? ( - + ) : ( )} @@ -272,9 +301,7 @@ const ComposerConnectionStatusPill = memo(function ComposerConnectionStatusPill( export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposerProps) { const navigation = useNavigation(); - const { themeAppearance } = useAppearancePreferences(); - const isDarkMode = themeAppearance === "dark"; - const foregroundColor = useThemeColor("--color-foreground"); + const foregroundColor = useUniwindTheme()["--color-foreground"]; const bodyText = useScaledTextRole("body"); const fallbackInputRef = useRef(null); const inputRef = props.editorRef ?? fallbackInputRef; @@ -289,48 +316,13 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer const inFlightThreadIdsRef = useRef(new Set()); const { onExpandedChange } = props; - const [previewImageUri, setPreviewImageUri] = useState(null); + const [previewFile, setPreviewFile] = useState(null); + const [previewVideo, setPreviewVideo] = useState(null); const hasContent = props.draftMessage.trim().length > 0 || props.draftAttachments.length > 0; - // Opening and presentation count as active so the composer stays expanded - // while focus moves between its native editor and the settings picker. - const isExpanded = isFocused || settingsSheetPresentation.isActive; - const canSend = hasContent; - - // Notify the parent from the derived value, not focus events: the parent - // sizes the feed inset from this, and blur-during-sheet would otherwise - // report collapsed while the composer still renders expanded. - useEffect(() => { - onExpandedChange?.(isExpanded); - }, [isExpanded, onExpandedChange]); - - const onPressImage = useCallback( - (uri: string) => { - wasExpandedBeforePreviewRef.current = isFocused; - setPreviewImageUri(uri); - }, - [isFocused], - ); - - const closePreview = useCallback(() => { - setPreviewImageUri(null); - if (wasExpandedBeforePreviewRef.current) { - setTimeout(() => inputRef.current?.focus(), 100); - } - }, [inputRef]); - - const onEditorFocusChange = props.onEditorFocusChange; - const handleFocus = useCallback(() => { - setIsFocused(true); - onEditorFocusChange?.(true); - }, [onEditorFocusChange]); - - const handleBlur = useCallback(() => { - setIsFocused(false); - onEditorFocusChange?.(false); - }, [onEditorFocusChange]); const showStopAction = - props.selectedThread.session?.status === "running" || - props.selectedThread.session?.status === "starting"; + !hasContent && + (props.selectedThread.session?.status === "running" || + props.selectedThread.session?.status === "starting"); const sendLabel = props.connectionState !== "connected" || props.queueCount > 0 ? "Queue" : "Send"; @@ -342,11 +334,6 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer environmentLabel: props.environmentLabel, threadSyncPhase: props.threadSyncPhase, }); - const toolbarSurface = String(useThemeColor("--color-card")); - const backdropSurface = String(useThemeColor("--color-screen")); - const toolbarFadeOpaque = themeColorWithAlpha(toolbarSurface, 0.95); - const toolbarFadeTransparent = themeColorWithAlpha(toolbarSurface, 0); - const backdropGradient = `linear-gradient(to bottom, ${themeColorWithAlpha(backdropSurface, 0)} 0%, ${themeColorWithAlpha(backdropSurface, 0.6)} 55%, ${themeColorWithAlpha(backdropSurface, 0.9)} 100%)`; const selectedProviderStatus = useMemo(() => { if (!props.serverConfig) return null; return ( @@ -355,199 +342,95 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer ) ?? null ); }, [props.serverConfig, props.selectedThread.modelSelection.instanceId]); + const composerOwnerKey = scopedThreadKey(props.environmentId, props.selectedThread.id); - // ── Trigger detection ──────────────────────────────────── - const [composerSelection, setComposerSelection] = useState(() => ({ - start: props.draftMessage.length, - end: props.draftMessage.length, - })); - - const handleSelectionChange = useCallback((selection: ComposerEditorSelection) => { - setComposerSelection(selection); - }, []); - useEffect(() => { - const end = props.draftMessage.length; - setComposerSelection((selection) => { - const start = Math.min(selection.start, end); - const selectionEnd = Math.min(selection.end, end); - if (start === selection.start && selectionEnd === selection.end) { - return selection; - } - return { start, end: selectionEnd }; - }); - }, [props.draftMessage.length]); - - const composerTrigger = useMemo(() => { - if (composerSelection.start !== composerSelection.end) { - return null; - } - return detectComposerTrigger(props.draftMessage, composerSelection.end); - }, [composerSelection, props.draftMessage]); - const pathSearch = useComposerPathSearch({ + const composerMenu = useComposerCommandMenu({ + draftMessage: props.draftMessage, + ownerKey: composerOwnerKey, + environmentId: props.environmentId, + projectCwd: props.projectCwd, + selectedProviderStatus, + hasThread: true, + onChangeDraftMessage: props.onChangeDraftMessage, + onUpdateInteractionMode: props.onUpdateInteractionMode, + }); + const voiceInput = useVoiceInputController({ + ownerKey: composerOwnerKey, + draftMessage: props.draftMessage, + selection: composerMenu.selection, + onChangeDraftMessage: props.onChangeDraftMessage, + onChangeSelection: composerMenu.onSelectionChange, + }); + const voicePresentation = resolveVoiceComposerPresentation( + voiceInput.state, + voiceInput.elapsedSeconds, + ); + const isVoiceInputPresented = voicePresentation.statusLabel !== null; + // An open draft stays visible; only a collapsed composer becomes a voice strip. + const isExpanded = isFocused || settingsSheetPresentation.isActive; + const showsCompactDictation = isVoiceInputPresented && !isExpanded; + const isToolbarVisible = isExpanded || isVoiceInputPresented; + const uploadStates = useAtomValue(composerAttachmentUploadsAtom); + const attachmentBlockReason = composerAttachmentUploadBlockReason({ environmentId: props.environmentId, - cwd: composerTrigger?.kind === "path" ? props.projectCwd : null, - query: composerTrigger?.kind === "path" ? composerTrigger.query : null, + attachments: props.draftAttachments, + connected: props.connectionState === "connected", + serverConfig: props.serverConfig, + states: uploadStates, }); + const canSend = hasContent && !voiceInput.blocksSubmission && attachmentBlockReason === null; - const composerMenuItems: ComposerCommandItem[] = useMemo(() => { - if (!composerTrigger) return []; + // Keep the feed inset aligned with the card or compact dictation strip. + useEffect(() => { + onExpandedChange?.(isExpanded); + }, [isExpanded, onExpandedChange]); - if (composerTrigger.kind === "slash-command") { - const q = composerTrigger.query.toLowerCase(); - const allBuiltIn = [ - { - id: "cmd:model", - type: "slash-command" as const, - command: "model", - label: "/model", - description: "Switch model", - }, - { - id: "cmd:plan", - type: "slash-command" as const, - command: "plan", - label: "/plan", - description: "Switch to plan mode", - }, - { - id: "cmd:default", - type: "slash-command" as const, - command: "default", - label: "/default", - description: "Switch to default mode", - }, - ]; - const builtIn = allBuiltIn.filter((item) => item.command.includes(q)); - - const providerCommands: ComposerCommandItem[] = []; - for (const cmd of selectedProviderStatus?.slashCommands ?? []) { - if (!cmd.name.toLowerCase().includes(q)) continue; - providerCommands.push({ - id: `pcmd:${cmd.name}`, - type: "provider-slash-command" as const, - command: cmd, - label: `/${cmd.name}`, - description: cmd.description ?? "", - }); - } + const onPressPreview = useCallback( + (source: FilePreviewSource) => { + wasExpandedBeforePreviewRef.current = isFocused; + setPreviewVideo(null); + setPreviewFile((current) => current ?? source); + }, + [isFocused], + ); - const skillItems = (selectedProviderStatus?.skills ?? []) - .filter((skill) => matchesSlashSkillQuery(skill, q)) - .map((skill) => ({ - id: `skill:${skill.name}`, - type: "skill" as const, - skill, - label: `skill:${skill.name}`, - description: skill.shortDescription ?? skill.description ?? "", - })); - - return [...builtIn, ...providerCommands, ...skillItems]; + const closePreview = useCallback(() => { + setPreviewFile(null); + setPreviewVideo(null); + if (wasExpandedBeforePreviewRef.current) { + setTimeout(() => { + if (navigation.isFocused()) inputRef.current?.focus(); + }, 100); } + }, [inputRef, navigation]); - if (composerTrigger.kind === "skill") { - const enabledSkills = (selectedProviderStatus?.skills ?? []).filter((s) => s.enabled); - const normalizedQuery = normalizeSearchQuery(composerTrigger.query, { - trimLeadingPattern: /^\$+/, - }); - - if (!normalizedQuery) { - return enabledSkills.slice(0, 20).map((skill) => ({ - id: `skill:${skill.name}`, - type: "skill" as const, - skill, - label: skill.displayName ?? skill.name, - description: skill.shortDescription ?? skill.description ?? "", - })); - } - - const ranked: Array<{ - item: (typeof enabledSkills)[number]; - score: number; - tieBreaker: string; - }> = []; - for (const skill of enabledSkills) { - const displayLabel = (skill.displayName ?? skill.name).toLowerCase(); - const scores = [ - scoreQueryMatch({ - value: skill.name.toLowerCase(), - query: normalizedQuery, - exactBase: 0, - prefixBase: 2, - boundaryBase: 4, - includesBase: 6, - fuzzyBase: 100, - boundaryMarkers: ["-", "_", "/"], - }), - scoreQueryMatch({ - value: displayLabel, - query: normalizedQuery, - exactBase: 1, - prefixBase: 3, - boundaryBase: 5, - includesBase: 7, - fuzzyBase: 110, - }), - scoreQueryMatch({ - value: skill.shortDescription?.toLowerCase() ?? "", - query: normalizedQuery, - exactBase: 20, - prefixBase: 22, - boundaryBase: 24, - includesBase: 26, - }), - scoreQueryMatch({ - value: skill.description?.toLowerCase() ?? "", - query: normalizedQuery, - exactBase: 30, - prefixBase: 32, - boundaryBase: 34, - includesBase: 36, - }), - ].filter((s): s is number => s !== null); - - if (scores.length > 0) { - insertRankedSearchResult( - ranked, - { - item: skill, - score: Math.min(...scores), - tieBreaker: `${displayLabel}\u0000${skill.name}`, - }, - 20, - ); - } - } + const onPressVideo = useCallback( + (attachment: DraftComposerFileAttachment, sourceIdentifier: string) => { + wasExpandedBeforePreviewRef.current = isFocused; + setPreviewFile(null); + setPreviewVideo((current) => current ?? { type: "local", attachment, sourceIdentifier }); + }, + [isFocused], + ); - return ranked.map(({ item: skill }) => ({ - id: `skill:${skill.name}`, - type: "skill" as const, - skill, - label: skill.displayName ?? skill.name, - description: skill.shortDescription ?? skill.description ?? "", - })); - } + const onEditorFocusChange = props.onEditorFocusChange; + const handleFocus = useCallback(() => { + setIsFocused(true); + onExpandedChange?.(true); + onEditorFocusChange?.(true); + }, [onEditorFocusChange, onExpandedChange]); - if (composerTrigger.kind === "path") { - return pathSearch.entries.map((entry) => { - const parts = entry.path.split("/"); - return { - id: `path:${entry.path}`, - type: "path" as const, - path: entry.path, - kind: entry.kind, - label: parts[parts.length - 1] ?? entry.path, - description: parts.length > 1 ? parts.slice(0, -1).join("/") : "", - }; - }); + const handleBlur = useCallback(() => { + setIsFocused(false); + if (!settingsSheetPresentation.isActive) { + onExpandedChange?.(false); } - - return []; - }, [composerTrigger, pathSearch.entries, selectedProviderStatus]); - - // ── Handle command selection ────────────────────────────── - const { onChangeDraftMessage, onUpdateInteractionMode, draftMessage, onSendMessage } = props; + onEditorFocusChange?.(false); + }, [onEditorFocusChange, onExpandedChange, settingsSheetPresentation.isActive]); + const { onSendMessage } = props; const handleSend = useCallback(async () => { + if (voiceInput.blocksSubmission) return; const threadKey = scopedThreadKey(props.environmentId, props.selectedThread.id); if (inFlightThreadIdsRef.current.has(threadKey)) return; inFlightThreadIdsRef.current.add(threadKey); @@ -574,49 +457,8 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer props.environmentLabel, props.selectedThread.id, props.selectedThread.title, + voiceInput.blocksSubmission, ]); - const handleCommandSelect = useCallback( - (item: ComposerCommandItem) => { - if (!composerTrigger) return; - - if ( - item.type === "slash-command" && - (item.command === "plan" || item.command === "default") - ) { - const result = replaceTextRange( - draftMessage, - composerTrigger.rangeStart, - composerTrigger.rangeEnd, - "", - ); - setComposerSelection({ start: result.cursor, end: result.cursor }); - onChangeDraftMessage(result.text); - onUpdateInteractionMode(item.command); - return; - } - - let replacement = ""; - if (item.type === "path") { - replacement = `${serializeComposerFileLink(item.path)} `; - } else if (item.type === "skill") { - replacement = `$${item.skill.name} `; - } else if (item.type === "slash-command") { - replacement = `/${item.command} `; - } else if (item.type === "provider-slash-command") { - replacement = `/${item.command.name} `; - } - - const result = replaceTextRange( - draftMessage, - composerTrigger.rangeStart, - composerTrigger.rangeEnd, - replacement, - ); - setComposerSelection({ start: result.cursor, end: result.cursor }); - onChangeDraftMessage(result.text); - }, - [composerTrigger, draftMessage, onChangeDraftMessage, onUpdateInteractionMode], - ); // ── Model menu ─────────────────────────────────────────── const modelOptions = useMemo( @@ -644,10 +486,11 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer }), [currentModelOption?.capabilities, currentModelSelection.options], ); - const settingsOwnerId = scopedThreadKey(props.environmentId, props.selectedThread.id); + const settingsOwnerId = composerOwnerKey; const settingsRouteSession = useMemo( () => ({ ownerId: settingsOwnerId, + environmentId: props.environmentId, providerGroups: threadProviderGroups, selectedModel: currentModelSelection, onSelectModel: (option) => props.onUpdateModelSelection(option.selection), @@ -712,8 +555,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer return ( - {composerTrigger && composerMenuItems.length > 0 ? ( + {!voiceInput.isBusy && composerMenu.trigger && composerMenu.items.length > 0 ? ( ) : null} @@ -755,7 +591,6 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer ) : null} - {/* Attachment strip — inside the card, above the text input */} - {isExpanded ? ( + + ) : null} + {!isExpanded ? ( + + {showStopAction ? ( - - ) : null} - - - - ) : null} + ) : ( + + )} + + ) : null} + {isExpanded ? : null} + + + + + + {isVoiceInputPresented ? ( + + ) : ( + + + + + } + label={currentModelOption?.label ?? currentModelSelection.model} + maxWidth={152} + onPress={openSettings} + /> + + + )} + + + {showStopAction ? ( + + ) : voicePresentation.showsSend ? ( + + ) : null} + + + + {/* Queue count */} @@ -919,14 +833,8 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer ) : null} - + + ); }); diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 2c6860199722..2736549b2b75 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -1,4 +1,8 @@ import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; +import { + appendCodexArtifactTemplateUsePrompt, + type CodexArtifactTemplate, +} from "@t3tools/client-runtime/codex-artifact-templates"; import type { EnvironmentThreadStatus } from "@t3tools/client-runtime/state/threads"; import { useKeyboardChatComposerInset, useKeyboardScrollToEnd } from "@legendapp/list/keyboard"; import type { LegendListRef } from "@legendapp/list/react-native"; @@ -27,7 +31,6 @@ import { useRef, useState, } from "react"; -import { isLiquidGlassSupported, LiquidGlassView } from "@callstack/liquid-glass"; import { AppState, Keyboard, @@ -45,17 +48,17 @@ import Animated, { Easing, FadeInDown, FadeOut, + ReduceMotion, useAnimatedReaction, useSharedValue, withTiming, } from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { ControlPill } from "../../components/ControlPill"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import type { ComposerEditorHandle } from "../../components/ComposerEditor"; import type { StatusTone } from "../../components/StatusPill"; -import type { DraftComposerImageAttachment } from "../../lib/composerImages"; +import type { DraftComposerAttachment } from "../../lib/composerImages"; import { CHAT_CONTENT_MAX_WIDTH, type LayoutVariant } from "../../lib/layout"; import { IOS_NAV_BAR_HEIGHT } from "../../lib/layoutMetrics"; import { scopedThreadKey } from "../../lib/scopedEntities"; @@ -67,6 +70,10 @@ import type { } from "../../lib/threadActivity"; import { PendingApprovalCard } from "./PendingApprovalCard"; import { PendingUserInputCard } from "./PendingUserInputCard"; +import { + FLOATING_WORKING_CONTROL_COVERAGE, + FloatingWorkingControl, +} from "./floating-working-control"; import { derivePendingUserInputMaxHeight, ESTIMATED_KEYBOARD_HEIGHT, @@ -75,6 +82,8 @@ import { import { COMPOSER_COLLAPSED_CHROME, COMPOSER_EXPANDED_CHROME, + COMPOSER_LAYOUT_TRANSITION, + COMPOSER_TRANSITION_DURATION_MS, ThreadComposer, } from "./ThreadComposer"; import { ThreadFeed } from "./ThreadFeed"; @@ -96,7 +105,7 @@ export interface ThreadDetailScreenProps { readonly activePendingUserInputAnswers: Record> | null; readonly respondingUserInputId: ApprovalRequestId | null; readonly draftMessage: string; - readonly draftAttachments: ReadonlyArray; + readonly draftAttachments: ReadonlyArray; readonly connectionStateLabel: EnvironmentConnectionPhase; /** Message sync status for the selected thread (drives the composer status pill). */ readonly threadSyncStatus?: EnvironmentThreadStatus; @@ -112,7 +121,8 @@ export interface ThreadDetailScreenProps { readonly onHeaderMaterialVisibilityChange?: (visible: boolean) => void; readonly onOpenConnectionEditor: () => void; readonly onChangeDraftMessage: (value: string) => void; - readonly onPickDraftImages: () => Promise; + readonly onPickDraftMedia: () => Promise; + readonly onPickDraftFiles: () => Promise; readonly onNativePasteImages: (uris: ReadonlyArray) => Promise; readonly onRemoveDraftImage: (imageId: string) => void; readonly onStopThread: () => void; @@ -254,12 +264,22 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread const agentLabel = `${props.selectedThread.modelSelection.instanceId} agent`; const selectedThreadKey = scopedThreadKey(props.environmentId, props.selectedThread.id); const composerEditorRef = useRef(null); + const draftMessageRef = useRef(props.draftMessage); + draftMessageRef.current = props.draftMessage; const composerOverlayRef = useRef(null); const listRef = useRef(null); const feedTouchStartRef = useRef<{ pageX: number; pageY: number } | null>(null); const selectedThreadKeyRef = useRef(selectedThreadKey); const lastScrolledSubmittedMessageIdRef = useRef(null); const [composerExpanded, setComposerExpanded] = useState(false); + const [composerFocused, setComposerFocused] = useState(false); + const handleComposerFocusChange = useCallback( + (focused: boolean) => { + setComposerFocused(focused); + handleOwnedInputFocusChange(focused); + }, + [handleOwnedInputFocusChange], + ); const [anchorMessageId, setAnchorMessageId] = useState(null); const [submittedMessageId, setSubmittedMessageId] = useState(null); const [endFollowEnabled, setEndFollowEnabled] = useState(true); @@ -270,7 +290,10 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread // animation, so the composer would ride down flush to the screen edge and // then snap up into the inset. On iOS blur precedes the hide, so the // focus-keyed inset is already in place while the composer rides down. - const composerBottomInset = (Platform.OS === "android" ? isKeyboardVisible : composerExpanded) + // Dictation keeps that focus while the composer switches to its compact pill. + const composerBottomInset = ( + Platform.OS === "android" ? isKeyboardVisible : composerExpanded || composerFocused + ) ? 0 : Math.max(insets.bottom, 12); const contentPresentationKind = props.contentPresentation.kind; @@ -290,6 +313,14 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread return null; } })(); + const showWorkingControl = + props.activeWorkStartedAt !== null && + contentPresentationKind === "ready" && + threadSyncPhase === null && + props.connectionStateLabel === "connected" && + props.activePendingApproval === null && + props.activePendingUserInput === null; + const floatingWorkingStartedAt = showWorkingControl ? props.activeWorkStartedAt : null; const selectedThreadFeed = props.selectedThreadFeed; const composerChrome = composerExpanded ? COMPOSER_EXPANDED_CHROME : COMPOSER_COLLAPSED_CHROME; const composerOverlapHeight = composerChrome + composerBottomInset; @@ -343,6 +374,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread composerOverlayRef, Math.max(0, estimatedOverlayHeight - nativeInsetOvercount), -nativeInsetOvercount, + Platform.OS === "ios" ? COMPOSER_TRANSITION_DURATION_MS : 0, ); // The expanded questionnaire is an absolute overlay on iOS, so it never // changes the measured overlay height (that constancy is what keeps the @@ -356,6 +388,15 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread const userInputCardProgress = useSharedValue(1); const userInputInsetProgress = useSharedValue(1); const userInputCardCoverage = useSharedValue(0); + const floatingControlCoverage = useSharedValue( + showWorkingControl ? FLOATING_WORKING_CONTROL_COVERAGE : 0, + ); + useEffect(() => { + floatingControlCoverage.value = withTiming( + showWorkingControl ? FLOATING_WORKING_CONTROL_COVERAGE : 0, + { duration: 180, reduceMotion: ReduceMotion.System }, + ); + }, [floatingControlCoverage, showWorkingControl]); // Android renders the expanded card in-flow (it cannot hit-test the iOS // overlay outside the bar's bounds), so its measured overlay height already // includes the card — the coverage extra is iOS-only. @@ -366,6 +407,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread useAnimatedReaction( () => contentInsetEndAdjustment.value + + floatingControlCoverage.value + (userInputCoverageApplies ? userInputInsetProgress.value * userInputCardCoverage.value : 0), (value) => { combinedContentInsetEndAdjustment.value = value; @@ -375,20 +417,24 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread const { freeze, scrollMessageToEnd } = useKeyboardScrollToEnd({ listRef }); const endFollowEnabledRef = useRef(true); endFollowEnabledRef.current = endFollowEnabled; - const userInputRepinTimerRef = useRef | null>(null); + const overlayRepinTimerRef = useRef | null>(null); + const previousWorkingControlStateRef = useRef({ + threadKey: selectedThreadKey, + visible: false, + }); // The list's own corrections for these inset changes drift on short // content (and the error compounds across toggles), so deterministically // re-pin the end once a toggle settles: a no-op when the resting position // is already right, corrective when it is not. Follow state is re-checked // inside the callback — the user may grab the list during the settle // window, and yanking them back would override a live gesture. - const scheduleUserInputRepin = useCallback( + const scheduleOverlayRepin = useCallback( (delayMs: number) => { - if (userInputRepinTimerRef.current !== null) { - clearTimeout(userInputRepinTimerRef.current); + if (overlayRepinTimerRef.current !== null) { + clearTimeout(overlayRepinTimerRef.current); } - userInputRepinTimerRef.current = setTimeout(() => { - userInputRepinTimerRef.current = null; + overlayRepinTimerRef.current = setTimeout(() => { + overlayRepinTimerRef.current = null; if (!endFollowEnabledRef.current) { return; } @@ -401,12 +447,29 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread ); useEffect( () => () => { - if (userInputRepinTimerRef.current !== null) { - clearTimeout(userInputRepinTimerRef.current); + if (overlayRepinTimerRef.current !== null) { + clearTimeout(overlayRepinTimerRef.current); } }, [], ); + useEffect(() => { + const previous = previousWorkingControlStateRef.current; + const threadChanged = previous.threadKey !== selectedThreadKey; + const visibilityChanged = previous.visible !== showWorkingControl; + previousWorkingControlStateRef.current = { + threadKey: selectedThreadKey, + visible: showWorkingControl, + }; + if ((!threadChanged && !visibilityChanged) || (threadChanged && !showWorkingControl)) { + return; + } + // LegendList applies the larger inset but does not re-anchor short + // followed conversations when this floating coverage changes after the + // initial load. Re-pin after the finite inset transition; the callback + // checks follow state again so a user who scrolled up stays put. + scheduleOverlayRepin(230); + }, [scheduleOverlayRepin, selectedThreadKey, showWorkingControl]); const handleToggleUserInputCollapsed = useCallback(() => { if (activeUserInputRequestId === null) { return; @@ -416,7 +479,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread userInputCardProgress.value = withTiming(1, USER_INPUT_TOGGLE_TIMING); userInputInsetProgress.value = withTiming(1, USER_INPUT_TOGGLE_TIMING); setCollapsedUserInputRequestId(null); - scheduleUserInputRepin(USER_INPUT_TOGGLE_DURATION_MS + 50); + scheduleOverlayRepin(USER_INPUT_TOGGLE_DURATION_MS + 50); } else { // Collapsing hides the custom-answer inputs; release the keyboard with // them instead of leaving it up over a dead responder. @@ -427,11 +490,11 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread // anchor. userInputInsetProgress.value = 0; setCollapsedUserInputRequestId(activeUserInputRequestId); - scheduleUserInputRepin(60); + scheduleOverlayRepin(60); } }, [ activeUserInputRequestId, - scheduleUserInputRepin, + scheduleOverlayRepin, userInputCardProgress, userInputCollapsed, userInputInsetProgress, @@ -456,7 +519,9 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread useLayoutEffect(() => { selectedThreadKeyRef.current = selectedThreadKey; - }, [selectedThreadKey]); + // A replaced or unmounted native editor may not emit a blur event. + setComposerFocused(false); + }, [selectedThreadKey, showContent]); useEffect(() => { setAnchorMessageId(null); @@ -555,6 +620,22 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread composerEditorRef.current?.blur(); }, []); + const handleUseArtifactTemplate = useCallback( + (template: CodexArtifactTemplate) => { + const currentDraft = draftMessageRef.current; + const nextDraft = appendCodexArtifactTemplateUsePrompt(currentDraft, template); + if (nextDraft !== currentDraft) { + draftMessageRef.current = nextDraft; + props.onChangeDraftMessage(nextDraft); + } + requestAnimationFrame(() => { + composerEditorRef.current?.focus(); + composerEditorRef.current?.setSelection({ start: nextDraft.length, end: nextDraft.length }); + }); + }, + [props.onChangeDraftMessage], + ); + const handleScrollToEnd = useCallback(() => { void Haptics.selectionAsync(); void scrollMessageToEnd({ animated: true, closeKeyboard: false }).catch(() => { @@ -607,7 +688,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread onTouchCancel={handleFeedTouchCancel} > @@ -639,137 +723,111 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread {/* Floating composer — sticks to keyboard via KeyboardStickyView */} {showContent ? ( - {/* No paddingTop here: the overlay's measured height becomes the - list's bottom inset, so any padding above the pill/composer - pushes the resting content floor up by the same amount. */} - - {showScrollToEndButton ? ( - - {isLiquidGlassSupported ? ( - + {/* No paddingTop here: the overlay's measured height becomes the + list's bottom inset, so any padding above the pill/composer + pushes the resting content floor up by the same amount. */} + + + + {props.activePendingApproval || props.activePendingUserInput ? ( + - - - ) : ( - - )} - - ) : null} - - {props.activePendingApproval || props.activePendingUserInput ? ( - - {props.activePendingApproval ? ( - - ) : null} - {props.activePendingUserInput ? ( - - ) : null} - - ) : null} - - - {/* Hidden (not unmounted) while a user-input request owns the + {props.activePendingApproval ? ( + + ) : null} + {props.activePendingUserInput ? ( + + ) : null} + + ) : null} + + + {/* Hidden (not unmounted) while a user-input request owns the composer slot, so composer drafts and editor state survive. */} - - + + + - + ) : null} diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 60b397802ccc..3cbf02efa2ba 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -1,14 +1,39 @@ import * as Haptics from "expo-haptics"; import { KeyboardAwareLegendList } from "@legendapp/list/keyboard"; -import { type LegendListRef } from "@legendapp/list/react-native"; -import type { EnvironmentId, MessageId, ThreadId, TurnId } from "@t3tools/contracts"; -import { classifyMarkdownImageSource } from "@t3tools/client-runtime/markdown-images"; +import { useViewabilityAmount, type LegendListRef } from "@legendapp/list/react-native"; +import type { + AssetResource, + ChatAttachment, + ChatFileAttachment, + ChatImageAttachment, + EnvironmentId, + MessageId, + ThreadId, + TurnId, +} from "@t3tools/contracts"; +import { + codexArtifactTemplatePresentationLabel, + type CodexArtifactTemplate, +} from "@t3tools/client-runtime/codex-artifact-templates"; +import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; +import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments"; +import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; +import { + classifyMarkdownImageSource, + markdownImageSourceFragment, +} from "@t3tools/client-runtime/markdown-images"; +import { resolveViewedImageAsset } from "@t3tools/client-runtime/work-log/presentation"; +import { + renderCodexFileCitationsAsMarkdown, + splitCodexArtifactTemplateMarkdown, +} from "@t3tools/client-runtime/codex-markdown-directives"; import { CHAT_LIST_ANCHOR_OFFSET, resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList"; -import { formatElapsed } from "@t3tools/shared/orchestrationTiming"; -import { SymbolView } from "../../components/AppSymbol"; +import { videoMimeType } from "@t3tools/shared/video"; +import { SymbolView, type AppSymbolName } from "../../components/AppSymbol"; import { HeaderHeightContext } from "@react-navigation/elements"; -import { useNavigation } from "@react-navigation/native"; +import { useFocusEffect, useNavigation } from "@react-navigation/native"; import { + createContext, memo, useCallback, useContext, @@ -17,6 +42,7 @@ import { useMemo, useRef, useState, + useId, type ReactNode, type RefObject, } from "react"; @@ -28,6 +54,7 @@ import { } from "react-native-nitro-markdown"; import { ActivityIndicator, + Alert, Image, Platform, type LayoutChangeEvent, @@ -42,16 +69,23 @@ import { View, type ViewStyle, } from "react-native"; -import { TouchableOpacity } from "react-native-gesture-handler"; -import ImageViewing from "react-native-image-viewing"; +import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal"; +import { isPdfFile } from "../../lib/filePreview"; +import { PresentationSource } from "../../components/NativePresentation"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import Animated, { FadeIn, FadeInUp, type SharedValue } from "react-native-reanimated"; -import { useThemeColor } from "../../lib/useThemeColor"; +import Animated, { + FadeIn, + FadeInUp, + LinearTransition, + type SharedValue, +} from "react-native-reanimated"; +import { useUniwindTheme } from "../../lib/useUniwindTheme"; import { IOS_NAV_BAR_HEIGHT } from "../../lib/layoutMetrics"; import { useFontFamily } from "../../lib/useFontFamily"; import { scopedThreadKey } from "../../lib/scopedEntities"; import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; import { tryOpenExternalUrl } from "../../lib/openExternalUrl"; +import { downloadAndShareAttachment } from "../../lib/attachmentDownload"; import { hasWideMarkdownBlock } from "../../lib/wideMarkdownBlocks"; import { hasNativeSelectableMarkdownText, @@ -62,6 +96,17 @@ import { } from "../../native/SelectableMarkdownText"; import { AppText as Text } from "../../components/AppText"; +import { VideoPreviewModal, type VideoPreviewSource } from "../../components/VideoPreviewModal"; +import { VideoAttachmentTile } from "../../components/VideoAttachmentTile"; +import { MediaVideoPlayer } from "../../components/MediaVideoPlayer"; +import { resolveMarkdownMediaPreview } from "../../lib/markdownMedia"; +import { useMediaActions, type MediaActionsSource } from "../../lib/mediaActions"; +import { MediaActionsMenu } from "../../components/MediaActionsMenu"; +import { + mediaVideoPreviewUri, + mediaVideoThumbnailKey, + type MediaVideoPreviewSource, +} from "../../lib/videoPreviewSource"; import { CopyTextButton } from "../../components/CopyTextButton"; import { parseReviewCommentMessageSegments, @@ -84,13 +129,15 @@ import { import { resolveMarkdownFontSizes, resolveNativeMarkdownTypography, - scaledTypographyLineHeight, } from "../../lib/appearancePreferences"; -import { MOBILE_TYPOGRAPHY } from "../../lib/typography"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { useAppearanceCodeSurface } from "../settings/appearance/useAppearanceCodeSurface"; import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons"; -import { resolveMarkdownLinkPresentation } from "@t3tools/mobile-markdown-text/links"; +import { + normalizeNativeMarkdownUrl, + resolveMarkdownInlineCodePresentation, + resolveMarkdownLinkPresentation, +} from "@t3tools/mobile-markdown-text/links"; import { deriveThreadFeedPresentation, type ThreadFeedEntry, @@ -103,12 +150,22 @@ import { } from "./thread-feed-live-follow"; import { collapsedWorkLogHeight, + ThreadDisclosureChevron, ThreadWorkGroupToggle, ThreadWorkLog, + THREAD_DISCLOSURE_TRANSITION_MS, WORK_GROUP_TOGGLE_HEIGHT, } from "./thread-work-log"; import { useMarkdownCodeHighlight } from "./markdownCodeHighlightState"; -import { useAssetUrl, useAssetUrlState } from "../../state/assets"; +import { + assetEnvironment, + useAssetUrl, + useAssetUrlState, + useRefreshAssetUrl, +} from "../../state/assets"; +import { useAtomQueryRunner } from "../../state/use-atom-query-runner"; +import { usePreparedConnection } from "../../state/session"; +import * as Option from "effect/Option"; import { resolveWorkspaceRelativeFilePath } from "../files/filePath"; import { MARKDOWN_IMAGE_MAX_WIDTH, resolveMarkdownImageDisplaySize } from "./markdownImageSize"; @@ -133,10 +190,12 @@ function formatMessageTime(input: string): string { // text-sm line at every supported base font size (26px at the 22pt maximum), // so its height is a constant; a drifted value costs one correction on // measure, not a persistent offset. -const TURN_FOLD_HEIGHT = 56; // min-h-11 (44) + mb-3 (12) -// The working row has no min-height clamp — its height follows the scaled -// text-xs line height (see workingRowHeight in ThreadFeed). -const WORKING_ROW_VERTICAL_EXTRAS = 24; // py-1 (8) + mb-4 (16) +const TURN_FOLD_HEIGHT = 48; // min-h-11 (44) + mb-1 (4) +const THREAD_FEED_LAYOUT_TRANSITION = LinearTransition.duration(THREAD_DISCLOSURE_TRANSITION_MS); +// Let neighboring rows move out of the new rows' space before showing their text. +const THREAD_FEED_DISCLOSURE_ENTER_TRANSITION = FadeIn.delay( + THREAD_DISCLOSURE_TRANSITION_MS, +).duration(140); // Entering animations must only play for rows born just now — LegendList // remounts rows when they scroll back into view, and replaying an entrance for @@ -169,6 +228,7 @@ export interface ThreadFeedProps { readonly onHeaderMaterialVisibilityChange?: (visible: boolean) => void; readonly onEndFollowEnabledChange?: (enabled: boolean) => void; readonly skills?: ReadonlyArray; + readonly onUseArtifactTemplate?: (template: CodexArtifactTemplate) => void; /** Non-null when older turns exist beyond the loaded window. */ readonly loadEarlier?: { readonly loading: boolean; @@ -179,9 +239,11 @@ export interface ThreadFeedProps { function MessageAttachmentImage(props: { readonly environmentId: EnvironmentId; readonly attachmentId: string; + readonly name: string; readonly className: string; - readonly onPressImage: (uri: string, headers?: Record) => void; + readonly onPressPreview: (source: FilePreviewSource) => void; }) { + const sourceIdentifier = useId(); const uri = useAssetUrl(props.environmentId, { _tag: "attachment", attachmentId: props.attachmentId, @@ -196,9 +258,213 @@ function MessageAttachmentImage(props: { } return ( - props.onPressImage(uri)}> - - + + + props.onPressPreview({ kind: "image", uri, name: props.name, sourceIdentifier }) + } + > + + + + ); +} + +// The attachment union has an open member (`type: string` for attachment +// types from newer servers), so literal comparisons do not narrow it. Split +// with guards and render unknown types as inert rows, never crash. +function isImageAttachment(attachment: ChatAttachment): attachment is ChatImageAttachment { + return attachment.type === "image"; +} + +function isFileAttachment(attachment: ChatAttachment): attachment is ChatFileAttachment { + return attachment.type === "file"; +} + +function MessageAttachmentFile(props: { + readonly environmentId: EnvironmentId; + readonly attachment: ChatFileAttachment; + readonly onPressPreview: (source: FilePreviewSource) => void; + readonly onPressVideo: (attachment: ChatFileAttachment, sourceIdentifier: string) => void; +}) { + const sourceIdentifier = useId(); + const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { + refresh: true, + reportFailure: false, + }); + const preparedConnection = usePreparedConnection(props.environmentId); + const { attachment } = props; + const videoType = videoMimeType(attachment); + const isPdf = isPdfFile(attachment); + const fileTypeLabel = isPdf + ? "PDF" + : (attachment.name.match(/\.([a-z0-9]{1,8})$/i)?.[1]?.toUpperCase() ?? "File"); + const sizeLabel = formatAttachmentSize(attachment.sizeBytes); + const thumbnailUrl = useAssetUrl( + props.environmentId, + videoType === null + ? null + : { + _tag: "attachment", + attachmentId: attachment.id, + fileName: attachment.name, + mimeType: videoType, + }, + ); + const httpBaseUrl = Option.isSome(preparedConnection) + ? preparedConnection.value.httpBaseUrl + : null; + const openingRef = useRef(null); + const [opening, setOpening] = useState(false); + + useFocusEffect( + useCallback(() => { + setOpening(false); + return () => { + openingRef.current?.abort(); + openingRef.current = null; + }; + }, [props.environmentId, attachment.id, httpBaseUrl]), + ); + + const shareFile = (sourceIdentifier?: string) => { + if (httpBaseUrl === null || openingRef.current) return; + const controller = new AbortController(); + openingRef.current = controller; + setOpening(true); + void (async () => { + try { + const result = await createAssetUrl({ + environmentId: props.environmentId, + input: { + resource: { + _tag: "attachment", + attachmentId: attachment.id, + fileName: attachment.name, + mimeType: attachment.mimeType, + }, + }, + }); + if (controller.signal.aborted) return; + if (result._tag === "Failure") { + throw squashAtomCommandFailure(result); + } + const url = resolveAssetUrl(httpBaseUrl, result.value.relativeUrl); + if (url === null) { + throw new Error("The attachment could not be opened."); + } + await downloadAndShareAttachment({ + url, + attachment, + signal: controller.signal, + sourceIdentifier, + }); + } catch (error) { + if (!controller.signal.aborted) { + Alert.alert( + "Could not open attachment", + error instanceof Error ? error.message : "The attachment is unavailable.", + ); + } + } finally { + if (openingRef.current === controller) { + openingRef.current = null; + setOpening(false); + } + } + })(); + }; + + if (videoType !== null) { + return ( + props.onPressVideo(attachment, sourceIdentifier)} + onShare={() => shareFile(`attachment:${props.environmentId}:${attachment.id}`)} + className="my-1 rounded-2xl" + style={{ width: 224, maxWidth: "100%", aspectRatio: 16 / 9 }} + /> + ); + } + + return ( + + + isPdf + ? props.onPressPreview({ + kind: "pdf", + name: attachment.name, + environmentId: props.environmentId, + resource: { + _tag: "attachment", + attachmentId: attachment.id, + fileName: attachment.name, + mimeType: "application/pdf", + }, + sourceIdentifier, + }) + : shareFile(sourceIdentifier) + } + > + + {opening ? ( + + ) : ( + + )} + + + + {attachment.name} + + + {fileTypeLabel} · {sizeLabel} + + + + + + ); +} + +/** + * An attachment type this build does not know (newer server). Rendered as an + * inert row: the name is still useful, but there is nothing to open. + */ +function MessageAttachmentUnknown(props: { readonly name: string }) { + return ( + + + + {props.name} + + ); } @@ -207,9 +473,11 @@ function ThreadMarkdownImageView(props: { readonly sourceKey: string; readonly unavailable: boolean; readonly alt: string | null; - readonly onPressImage: (uri: string) => void; + readonly actionsSource?: MediaActionsSource; + readonly onPressPreview: (source: FilePreviewSource) => void; }) { - const codeBackground = useThemeColor("--color-md-code-bg"); + const sourceIdentifier = useId(); + const mediaActions = useMediaActions(props.actionsSource); const [availableWidth, setAvailableWidth] = useState(0); const [sourceSize, setSourceSize] = useState<{ width: number; height: number } | null>(null); const [failedUri, setFailedUri] = useState(null); @@ -242,12 +510,9 @@ function ThreadMarkdownImageView(props: { > {props.uri === null || failed ? ( {failed ? ( @@ -255,33 +520,52 @@ function ThreadMarkdownImageView(props: { ) : ( )} + {props.actionsSource ? ( + + + + ) : null} ) : ( - props.onPressImage(props.uri!)} - style={{ alignSelf: "flex-start" }} - > - - setFailedUri(props.uri)} - /> + + + + + props.onPressPreview({ + kind: "image", + uri: props.uri!, + name: props.alt ?? "Image", + sourceIdentifier, + actionsSource: props.actionsSource, + }) + } + style={{ alignSelf: "flex-start" }} + > + + setFailedUri(props.uri)} + /> + + + + {props.actionsSource ? ( + + + + ) : null} - + )} {props.alt ? ( @@ -324,27 +608,76 @@ function ThreadMarkdownImageRequest(props: { ); } -/** Markdown image whose src is a workspace file — loads through a signed asset URL. */ +/** Environment-hosted image that loads through a signed asset URL. */ function ThreadMarkdownImage(props: { readonly environmentId: EnvironmentId; - readonly threadId: ThreadId; - readonly path: string; + readonly resource: Extract; readonly alt: string | null; - readonly onPressImage: (uri: string) => void; + readonly srcFragment?: string; + readonly actionsSource?: MediaActionsSource; + readonly onPressPreview: (source: FilePreviewSource) => void; }) { - const assetUrl = useAssetUrlState(props.environmentId, { - _tag: "workspace-file", - threadId: props.threadId, - path: props.path, - }); + const assetUrl = useAssetUrlState(props.environmentId, props.resource); return ( + ); +} + +const ThreadMediaVisibleContext = createContext(false); +// LegendList only computes hook visibility when the list has a viewability config. +const THREAD_MEDIA_VIEWABILITY_CONFIG = { itemVisiblePercentThreshold: 0 }; + +function ThreadMediaVisibility(props: { readonly children: ReactNode }) { + const [visible, setVisible] = useState(false); + useViewabilityAmount( + useCallback((token) => setVisible(token.sizeVisible > 0), []), + ); + return {props.children}; +} + +function ThreadMarkdownVideo(props: { + readonly source: MediaVideoPreviewSource; + readonly onExpand: (source: MediaVideoPreviewSource) => void; +}) { + const { source } = props; + const visible = useContext(ThreadMediaVisibleContext); + const thumbnailKey = mediaVideoThumbnailKey(source); + const asset = useAssetUrlState( + "environmentId" in source ? source.environmentId : null, + "resource" in source ? source.resource : null, + ); + const refreshAssetUrl = useRefreshAssetUrl( + "environmentId" in source ? source.environmentId : null, + "resource" in source ? source.resource : null, + ); + const uri = mediaVideoPreviewUri(source, asset._tag === "Success" ? asset.url : null); + return ( + mediaVideoPreviewUri(source, await refreshAssetUrl()) + : undefined + } + name={source.name} + thumbnailKey={thumbnailKey} + thumbnailVisible={visible} + unavailable={"resource" in source && asset._tag === "Failure"} + actionsSource={source.actionsSource} + onExpand={() => props.onExpand(source)} /> ); } @@ -356,7 +689,7 @@ function ThreadMarkdownImageUnavailable(props: { readonly alt: string | null }) sourceKey="unavailable" unavailable alt={props.alt} - onPressImage={() => undefined} + onPressPreview={() => undefined} /> ); } @@ -389,6 +722,7 @@ interface ReviewCommentColors { } const failedMarkdownFaviconHosts = new Set(); +const MarkdownLinkLabelContext = createContext(false); const markdownLinkStyles = StyleSheet.create({ inlineIcon: { width: 14, @@ -406,15 +740,14 @@ const MarkdownExternalLink = memo(function MarkdownExternalLink(props: { readonly color: string; readonly host: string; readonly href: string; + readonly onPress: (href: string) => void; }) { const [failed, setFailed] = useState(() => failedMarkdownFaviconHosts.has(props.host)); return ( { - void tryOpenExternalUrl(props.href, "markdown-link"); - }} + onPress={() => props.onPress(props.href)} style={{ color: props.color, textDecorationLine: "none", @@ -439,6 +772,146 @@ const MarkdownExternalLink = memo(function MarkdownExternalLink(props: { ); }); +function MarkdownInlineCode(props: { + readonly content: string; + readonly textColor: string; + readonly codeColor: string; + readonly fontSize: number; + readonly lineHeight: number; + readonly onLinkPress: (href: string) => void; +}) { + const insideLink = useContext(MarkdownLinkLabelContext); + const presentation = insideLink ? null : resolveMarkdownInlineCodePresentation(props.content); + return ( + props.onLinkPress(presentation.href) : undefined} + style={{ + color: presentation ? props.textColor : props.codeColor, + fontSize: props.fontSize, + lineHeight: props.lineHeight, + }} + > + {presentation ? ( + + ) : null} + {presentation?.label ?? props.content} + + ); +} + +const ARTIFACT_TEMPLATE_SYMBOL_BY_KIND: Record< + CodexArtifactTemplate["artifactKind"], + AppSymbolName +> = { + document: "doc.text", + presentation: "chart.bar.xaxis", + spreadsheet: "chart.bar.xaxis", + site: "safari", + "google-docs": "doc.text", + "google-slides": "chart.bar.xaxis", + "google-sheets": "chart.bar.xaxis", + image: "camera", + email: "text.bubble", + slack: "text.bubble", +}; + +function ArtifactTemplateCard(props: { + readonly template: CodexArtifactTemplate; + readonly onUse?: ((template: CodexArtifactTemplate) => void) | undefined; +}) { + return ( + + + + + + + + + + {props.template.displayName} + + + {codexArtifactTemplatePresentationLabel(props.template.artifactKind)} + + + {props.onUse ? ( + props.onUse?.(props.template)} + > + Use template + + ) : null} + + ); +} + +const AssistantMarkdownContent = memo(function AssistantMarkdownContent(props: { + readonly markdown: string; + readonly markdownStyles: MarkdownStyleSet; + readonly onLinkPress: (href: string) => void; + readonly onUseArtifactTemplate?: ((template: CodexArtifactTemplate) => void) | undefined; + readonly renderImage: MarkdownImageRenderer; + readonly skills?: ReadonlyArray | undefined; +}) { + const segments = useMemo( + () => splitCodexArtifactTemplateMarkdown(props.markdown), + [props.markdown], + ); + + return segments.map((segment) => { + if (segment.kind === "artifact-template") { + return ( + + ); + } + if (segment.markdown.trim().length === 0) return null; + + const markdown = renderCodexFileCitationsAsMarkdown(segment.markdown); + return hasNativeSelectableMarkdownText() ? ( + + ) : ( + + {markdown} + + ); + }); +}); + function MarkdownCodeBlock(props: { readonly backgroundColor: string; readonly borderColor: string; @@ -556,23 +1029,18 @@ function MarkdownCodeBlock(props: { } function useReviewCommentColors(): ReviewCommentColors { - const background = useThemeColor("--color-card"); - const border = useThemeColor("--color-border"); - const mutedBackground = useThemeColor("--color-subtle"); - const text = useThemeColor("--color-foreground"); - const mutedText = useThemeColor("--color-foreground-muted"); - const codeBackground = useThemeColor("--color-md-code-bg"); + const theme = useUniwindTheme(); return useMemo( () => ({ - background, - border, - mutedBackground, - text, - mutedText, - codeBackground, + background: theme["--color-card"], + border: theme["--color-border"], + mutedBackground: theme["--color-subtle"], + text: theme["--color-foreground"], + mutedText: theme["--color-foreground-muted"], + codeBackground: theme["--color-md-code-bg"], }), - [background, border, codeBackground, mutedBackground, mutedText, text], + [theme], ); } @@ -590,25 +1058,26 @@ function useMarkdownStyles( [appearance.baseFontSize], ); const themeMode = themeAppearance; - const markdownBodyColor = String(useThemeColor("--color-md-body")); - const markdownStrongColor = String(useThemeColor("--color-md-strong")); - const markdownLinkColor = String(useThemeColor("--color-md-link")); - const markdownBlockquoteBg = String(useThemeColor("--color-md-blockquote-bg")); - const markdownBlockquoteBorder = String(useThemeColor("--color-md-blockquote-border")); - const markdownCodeBg = String(useThemeColor("--color-md-code-bg")); - const markdownCodeText = String(useThemeColor("--color-md-code-text")); - const markdownInlineCodeText = String(useThemeColor("--color-foreground-secondary")); - const markdownHrColor = String(useThemeColor("--color-md-hr")); - const markdownUserBodyColor = String(useThemeColor("--color-user-bubble-foreground")); - const markdownUserCodeBg = String(useThemeColor("--color-md-user-code-bg")); - const markdownUserCodeText = String(useThemeColor("--color-md-user-code-text")); - const markdownUserInlineCodeText = String(useThemeColor("--color-user-bubble-foreground-muted")); - const markdownUserFenceBg = String(useThemeColor("--color-md-user-fence-bg")); - const markdownUserFenceText = String(useThemeColor("--color-md-user-fence-text")); - const iconSubtleColor = String(useThemeColor("--color-icon-subtle")); - const inlineSkillForeground = String(useThemeColor("--color-inline-skill-foreground")); - const userBubbleSkillForeground = String(useThemeColor("--color-user-bubble-skill-foreground")); - const userBubbleForegroundMuted = String(useThemeColor("--color-user-bubble-foreground-muted")); + const theme = useUniwindTheme(); + const markdownBodyColor = theme["--color-md-body"]; + const markdownStrongColor = theme["--color-md-strong"]; + const markdownLinkColor = theme["--color-md-link"]; + const markdownBlockquoteBg = theme["--color-md-blockquote-bg"]; + const markdownBlockquoteBorder = theme["--color-md-blockquote-border"]; + const markdownCodeBg = theme["--color-md-code-bg"]; + const markdownCodeText = theme["--color-md-code-text"]; + const markdownInlineCodeText = theme["--color-foreground-secondary"]; + const markdownHrColor = theme["--color-md-hr"]; + const markdownUserBodyColor = theme["--color-user-bubble-foreground"]; + const markdownUserCodeBg = theme["--color-md-user-code-bg"]; + const markdownUserCodeText = theme["--color-md-user-code-text"]; + const markdownUserInlineCodeText = theme["--color-user-bubble-foreground-muted"]; + const markdownUserFenceBg = theme["--color-md-user-fence-bg"]; + const markdownUserFenceText = theme["--color-md-user-fence-text"]; + const iconSubtleColor = theme["--color-icon-subtle"]; + const inlineSkillForeground = theme["--color-inline-skill-foreground"]; + const userBubbleSkillForeground = theme["--color-user-bubble-skill-foreground"]; + const userBubbleForegroundMuted = theme["--color-user-bubble-foreground-muted"]; const regularFontFamily = useFontFamily("regular"); const boldFontFamily = useFontFamily("bold"); @@ -726,30 +1195,35 @@ function useMarkdownStyles( } if (presentation.kind === "external") { return ( - - {children} - + + + {children} + + ); } const linkHref = presentation.href; return ( - { - void tryOpenExternalUrl(linkHref, "markdown-link"); - } - : undefined - } - style={{ color: markdownLinkColor }} - > - {children} - + + { + void tryOpenExternalUrl(linkHref, "markdown-link"); + } + : undefined + } + style={{ color: markdownLinkColor }} + > + {children} + + ); }, list: ({ node, Renderer, ordered = false, start = 1 }) => ( @@ -792,21 +1266,16 @@ function useMarkdownStyles( title: node.title ?? null, }) ?? undefined) : undefined, - code_inline: ({ content }) => { - const value = content ?? ""; - return ( - - {value} - - ); - }, + code_inline: ({ content }) => ( + + ), ...(preserveSoftBreaks ? { soft_break: () => {"\n"}, @@ -974,7 +1443,7 @@ function useMarkdownStyles( function renderFeedEntry( info: { item: ThreadFeedEntry; index: number }, - props: Pick & { + props: Pick & { readonly copiedRowId: string | null; readonly expandedWorkRows: Record; readonly terminalAssistantMessageIds: ReadonlySet; @@ -983,9 +1452,11 @@ function renderFeedEntry( readonly onToggleWorkGroup: (groupId: string) => void; readonly onToggleWorkRow: (rowId: string) => void; readonly onToggleTurnFold: (turnId: TurnId) => void; - readonly onPressImage: (uri: string, headers?: Record) => void; + readonly onPressPreview: (source: FilePreviewSource) => void; + readonly onPressVideo: (attachment: ChatFileAttachment, sourceIdentifier: string) => void; readonly onMarkdownLinkPress: (href: string) => void; readonly renderMarkdownImage: MarkdownImageRenderer; + readonly renderViewedImage: MarkdownImageRenderer; readonly iconSubtleColor: string | import("react-native").ColorValue; readonly userBubbleColor: string | import("react-native").ColorValue; readonly markdownStyles: MarkdownStyleSets; @@ -997,10 +1468,6 @@ function renderFeedEntry( const entry = info.item; const { markdownStyles, iconSubtleColor, userBubbleColor } = props; - if (entry.type === "working") { - return ; - } - if (entry.type === "turn-fold") { return ( props.onToggleTurnFold(entry.turnId)} hitSlop={4} - className="mb-3 min-h-11 flex-row items-center gap-2 border-b border-neutral-200/80 px-2 dark:border-white/[0.08]" + className="mb-1 min-h-11 flex-row items-center gap-2 border-b border-adaptive-neutral-200-a80-white-a8 px-2" > {entry.label} - ); @@ -1029,7 +1496,10 @@ function renderFeedEntry( expanded={entry.expanded} hiddenCount={entry.hiddenCount} iconSubtleColor={iconSubtleColor} - onlyToolActivities={entry.onlyToolActivities} + summary={entry.summary} + summaryKind={entry.summaryKind} + hasFailure={entry.hasFailure} + shimmer={entry.shimmer} onToggle={() => props.onToggleWorkGroup(entry.groupId)} /> ); @@ -1038,6 +1508,7 @@ function renderFeedEntry( if (entry.type === "message") { const { message } = entry; const isUser = message.role === "user"; + const renderedText = message.text; const styles = isUser ? markdownStyles.user : markdownStyles.assistant; const timestampLabel = formatMessageTime(isUser ? message.createdAt : message.updatedAt); const attachments = message.attachments ?? []; @@ -1047,7 +1518,7 @@ function renderFeedEntry( // children during the unclamped pass and never moves them once the width // is clamped, so the paragraphs around the block end up drawn on top of // each other. Pinning the width removes that pass. - const hasWideBlock = hasWideMarkdownBlock(message.text, WIDE_MARKDOWN_BLOCK_OPTIONS); + const hasWideBlock = hasWideMarkdownBlock(renderedText, WIDE_MARKDOWN_BLOCK_OPTIONS); const assistantTurnStillInProgress = message.role === "assistant" && props.unsettledTurnId !== null && @@ -1088,19 +1559,30 @@ function renderFeedEntry( /> ) : null} {attachments.map((attachment) => { - return ( + return isImageAttachment(attachment) ? ( + ) : isFileAttachment(attachment) ? ( + + ) : ( + ); })} - + {timestampLabel} {message.text.trim().length > 0 ? ( @@ -1119,57 +1601,58 @@ function renderFeedEntry( // Skip empty assistant messages (no text, no attachments) — they would // render as an orphaned timestamp and break adjacent activity-group merging. - if (message.text.trim().length === 0 && attachments.length === 0) { + if (renderedText.trim().length === 0 && attachments.length === 0) { return null; } const enterAnimated = isFreshTimestamp(message.createdAt); return ( - {message.text.trim().length > 0 ? ( - hasNativeSelectableMarkdownText() ? ( - - ) : ( - - {message.text} - - ) + {renderedText.trim().length > 0 ? ( + ) : null} {attachments.map((attachment) => { - return ( + return isImageAttachment(attachment) ? ( + ) : isFileAttachment(attachment) ? ( + + ) : ( + ); })} {showAssistantMeta ? ( - + {timestampLabel} @@ -1186,36 +1669,11 @@ function renderFeedEntry( iconSubtleColor={iconSubtleColor} onCopyRow={props.onCopyWorkRow} onToggleRow={props.onToggleWorkRow} + renderImage={props.renderViewedImage} /> ); } -const WorkingTimelineRow = memo(function WorkingTimelineRow(props: { readonly startedAt: string }) { - const [nowMs, setNowMs] = useState(() => Date.now()); - - useEffect(() => { - const intervalId = setInterval(() => { - setNowMs(Date.now()); - }, 1_000); - return () => clearInterval(intervalId); - }, [props.startedAt]); - - const durationLabel = formatElapsed(props.startedAt, new Date(nowMs).toISOString()) ?? "0s"; - - return ( - - - - - - - - Working for {durationLabel} - - - ); -}); - function UserMessageContent(props: { readonly text: string; readonly markdownStyles: MarkdownStyleSet; @@ -1301,6 +1759,7 @@ const ReviewCommentCard = memo(function ReviewCommentCard(props: { }) { const { codeSurface, nativeReviewDiffStyle } = useAppearanceCodeSurface(); const { themeAppearance: appearanceScheme, themeId } = useAppearancePreferences(); + const appTheme = useUniwindTheme(); const NativeReviewDiffView = resolveNativeReviewDiffView(); const patch = useMemo(() => buildReviewCommentPatch(props.comment), [props.comment]); const parsedDiff = useMemo( @@ -1313,8 +1772,8 @@ const ReviewCommentCard = memo(function ReviewCommentCard(props: { [nativeReviewDiffData.rows], ); const nativeReviewDiffTheme = useMemo( - () => createNativeReviewDiffTheme(appearanceScheme, themeId), - [appearanceScheme, themeId], + () => createNativeReviewDiffTheme(appearanceScheme, themeId, appTheme), + [appearanceScheme, appTheme, themeId], ); const nativeRowsJson = useMemo(() => JSON.stringify(compactNativeRows), [compactNativeRows]); const nativeThemeJson = useMemo( @@ -1488,14 +1947,13 @@ function ThreadFeedPlaceholder(props: { export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { const navigation = useNavigation(); const copyFeedbackTimeoutRef = useRef | null>(null); - const foldSettleFrameRef = useRef(null); - const foldSettleSecondFrameRef = useRef(null); + const disclosureSettleFrameRef = useRef(null); + const disclosureSettleSecondFrameRef = useRef(null); const disclosureAnchorKeyRef = useRef(null); const headerMaterialVisibleRef = useRef(false); const previousLatestTurnRef = useRef(props.latestTurn); const userScrollSettleTimerRef = useRef | null>(null); const { width: windowWidth } = useWindowDimensions(); - const { appearance } = useAppearancePreferences(); const [viewportWidth, setViewportWidth] = useState(() => props.layoutVariant === "split" ? 0 : windowWidth, ); @@ -1504,12 +1962,12 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { // Live-follow latch. LegendList's maintainScrollAtEnd alone re-pins the feed // whenever the viewport drifts back inside its geometric threshold, which // yanked users off history they were reading every time a stream chunk grew - // a row. Follow breaks when the user scrolls up and away, and re-arms only - // when the list actually returns to the end (or on send / thread switch). + // a row. Scrolling away or expanding a disclosure above the end breaks + // follow; reaching the end (or sending / switching threads) re-arms it. const [endFollowEnabled, setEndFollowEnabled] = useState(true); const endFollowEnabledRef = useRef(true); // A "user scroll session" spans from drag start through the end of its - // momentum; only motion inside a session can break follow, so MVCP + // momentum; scroll events only break follow inside that session, so MVCP // compensations and programmatic scrolls never strand a follower. const userScrollSessionRef = useRef(false); const setEndFollow = useCallback( @@ -1541,10 +1999,12 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { expandedTurnIds: new Set(), }); const { copiedRowId, expandedWorkGroups, expandedWorkRows, expandedTurnIds } = interactionState; - const [expandedImage, setExpandedImage] = useState<{ - uri: string; - headers?: Record; - } | null>(null); + const [expandedFile, setExpandedFile] = useState(null); + const [expandedVideo, setExpandedVideo] = useState(null); + useEffect(() => { + setExpandedVideo(null); + setExpandedFile(null); + }, [props.environmentId, props.threadId, props.contentPresentation.kind]); const horizontalPadding = props.layoutVariant === "split" ? 20 : 16; const contentHorizontalPadding = deriveCenteredContentHorizontalPadding({ viewportWidth, @@ -1576,8 +2036,9 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { ? navigationHeaderHeight || insets.top + IOS_NAV_BAR_HEIGHT : topContentInset; - const iconSubtleColor = useThemeColor("--color-icon-subtle"); - const userBubbleColor = useThemeColor("--color-user-bubble"); + const theme = useUniwindTheme(); + const iconSubtleColor = theme["--color-icon-subtle"]; + const userBubbleColor = theme["--color-user-bubble"]; const onMarkdownLinkPress = useCallback( (href: string) => { const presentation = resolveMarkdownLinkPresentation(href); @@ -1588,17 +2049,54 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { ); if (relativePath) { void Haptics.selectionAsync(); + if (isPdfFile({ name: relativePath })) { + setExpandedFile( + (current) => + current ?? { + kind: "pdf", + name: relativePath.split("/").at(-1), + environmentId: props.environmentId, + resource: { + _tag: "workspace-file", + threadId: props.threadId, + path: relativePath, + }, + }, + ); + return; + } navigation.navigate("ThreadFile", { environmentId: String(props.environmentId), threadId: String(props.threadId), path: relativePath.split("/").filter((segment) => segment.length > 0), ...(presentation.line ? { line: String(presentation.line) } : {}), }); + return; + } + } + + const media = resolveMarkdownMediaPreview(href, { + environmentId: props.environmentId, + threadId: props.threadId, + workspaceRoot: props.workspaceRoot, + }); + if (media) { + void Haptics.selectionAsync(); + if (media.kind === "video") { + setExpandedVideo((current) => current ?? media.source); + } else { + setExpandedFile((current) => current ?? media.source); } return; } - if (presentation.href) { + if (presentation.kind !== "file" && presentation.href) { + if (/^https?:\/\//i.test(presentation.href) && isPdfFile({ name: presentation.href })) { + setExpandedFile( + (current) => current ?? { kind: "pdf", uri: presentation.href!, name: "Document.pdf" }, + ); + return; + } void tryOpenExternalUrl(presentation.href, "markdown-link"); } }, @@ -1606,15 +2104,31 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { ); const renderMarkdownImage = useCallback( (image) => { + const media = resolveMarkdownMediaPreview(image.href, { + environmentId: props.environmentId, + threadId: props.threadId, + workspaceRoot: props.workspaceRoot, + imageEmbed: true, + }); + if (media?.kind === "video") { + return ( + setExpandedVideo((current) => current ?? source)} + /> + ); + } const imageSource = classifyMarkdownImageSource(image.href, props.workspaceRoot ?? null); if (imageSource._tag === "Direct") { return ( setExpandedImage({ uri })} + actionsSource={media?.source.actionsSource} + onPressPreview={(source) => setExpandedFile((current) => current ?? source)} /> ); } @@ -1624,15 +2138,52 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { return ( setExpandedImage({ uri })} + srcFragment={markdownImageSourceFragment(image.href)} + actionsSource={media?.source.actionsSource} + onPressPreview={(source) => setExpandedFile((current) => current ?? source)} /> ); }, [props.environmentId, props.threadId, props.workspaceRoot], ); + const renderViewedImage = useCallback( + (image) => { + const viewedImage = resolveViewedImageAsset(image.href, { + threadId: props.threadId, + workspaceRoot: props.workspaceRoot, + }); + const media = viewedImage + ? resolveMarkdownMediaPreview(image.href, { + environmentId: props.environmentId, + threadId: props.threadId, + workspaceRoot: props.workspaceRoot, + imageEmbed: true, + }) + : null; + const actionsSource = media?.source.actionsSource; + return viewedImage ? ( + setExpandedFile((current) => current ?? source)} + /> + ) : null; + }, + [props.environmentId, props.threadId, props.workspaceRoot], + ); const markdownStyles = useMarkdownStyles(onMarkdownLinkPress, renderMarkdownImage); const reviewCommentColors = useReviewCommentColors(); // LegendList does not invalidate visible rows when only the renderItem closure changes. @@ -1799,14 +2350,10 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { props.latestTurn, ], ); - - // The empty↔filled key below remounts the list, which resets its imperative - // content-inset override — and useKeyboardChatComposerInset (mounted above - // the remount boundary) deduplicates by height, so it never re-reports the - // composer inset to the fresh instance. Re-report the measured overlay height - // (composer plus any pending approval / user-input card) so the remounted - // list's scroll math gets the true value; on Android the declarative - // contentInset floor below covers the window before this effect lands. + // The empty↔filled key below remounts the list and resets its imperative + // content-inset override. Seed the fresh instance synchronously with the + // current overlay height before the scroll integration's next reaction; + // on Android the declarative contentInset floor covers this same window. const listMountKey = `${feedThreadKey}:${props.feed.length === 0 ? "empty" : "filled"}`; useLayoutEffect(() => { const bottom = props.contentInsetEndAdjustment.value; @@ -1871,34 +2418,62 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { if (copyFeedbackTimeoutRef.current) { clearTimeout(copyFeedbackTimeoutRef.current); } - if (foldSettleFrameRef.current !== null) { - cancelAnimationFrame(foldSettleFrameRef.current); + if (disclosureSettleFrameRef.current !== null) { + cancelAnimationFrame(disclosureSettleFrameRef.current); } - if (foldSettleSecondFrameRef.current !== null) { - cancelAnimationFrame(foldSettleSecondFrameRef.current); + if (disclosureSettleSecondFrameRef.current !== null) { + cancelAnimationFrame(disclosureSettleSecondFrameRef.current); } }; }, []); - const suspendEndScrollMaintenanceForDisclosure = useCallback((anchorKey: string | null) => { - disclosureAnchorKeyRef.current = anchorKey; - setDisclosureToggleSettling(true); - if (foldSettleFrameRef.current !== null) { - cancelAnimationFrame(foldSettleFrameRef.current); + const settleDisclosureAfterLayout = useCallback(() => { + if (disclosureSettleFrameRef.current !== null) { + cancelAnimationFrame(disclosureSettleFrameRef.current); } - if (foldSettleSecondFrameRef.current !== null) { - cancelAnimationFrame(foldSettleSecondFrameRef.current); + if (disclosureSettleSecondFrameRef.current !== null) { + cancelAnimationFrame(disclosureSettleSecondFrameRef.current); } - foldSettleFrameRef.current = requestAnimationFrame(() => { - foldSettleSecondFrameRef.current = requestAnimationFrame(() => { + disclosureSettleFrameRef.current = requestAnimationFrame(() => { + disclosureSettleSecondFrameRef.current = requestAnimationFrame(() => { + // A disclosure can leave the reader above the end without a drag. + // Reconcile follow before a later layout or resume can re-pin it. + const listState = props.listRef.current?.getState(); + if (listState) { + transitionEndFollow({ + type: "disclosure-settled", + isAtEnd: listState.isAtEnd, + userScrollSessionActive: userScrollSessionRef.current, + }); + } disclosureAnchorKeyRef.current = null; setDisclosureToggleSettling(false); - foldSettleFrameRef.current = null; - foldSettleSecondFrameRef.current = null; + disclosureSettleFrameRef.current = null; + disclosureSettleSecondFrameRef.current = null; }); }); + }, [props.listRef, transitionEndFollow]); + + const suspendEndScrollMaintenanceForDisclosure = useCallback((anchorKey: string | null) => { + disclosureAnchorKeyRef.current = anchorKey; + setDisclosureToggleSettling(true); }, []); + // Start the quiet-frame countdown after React has committed the disclosure. + // Every measured item-size change restarts it, so end maintenance cannot + // wake between the data mutation and LegendList's final layout correction. + useLayoutEffect(() => { + if (disclosureAnchorKeyRef.current !== null) { + settleDisclosureAfterLayout(); + } + }, [expandedTurnIds, expandedWorkGroups, expandedWorkRows, settleDisclosureAfterLayout]); + + const handleItemSizeChanged = useCallback(() => { + if (disclosureAnchorKeyRef.current !== null) { + settleDisclosureAfterLayout(); + } + }, [settleDisclosureAfterLayout]); + const shouldRestoreVisibleContentPosition = useCallback((entry: ThreadFeedEntry) => { const disclosureAnchorKey = disclosureAnchorKeyRef.current; return disclosureAnchorKey === null || entry.id === disclosureAnchorKey; @@ -1974,20 +2549,30 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { [suspendEndScrollMaintenanceForDisclosure], ); - const onPressImage = useCallback((uri: string, headers?: Record) => { - setExpandedImage({ uri, headers }); + const onPressPreview = useCallback((source: FilePreviewSource) => { + setExpandedFile((current) => current ?? source); }, []); + const onPressVideo = useCallback( + (attachment: ChatFileAttachment, sourceIdentifier: string) => { + setExpandedVideo( + (current) => + current ?? { + type: "remote", + environmentId: props.environmentId, + attachment, + sourceIdentifier, + }, + ); + }, + [props.environmentId], + ); // Rows whose height is known before they ever render. Without this, every // row above the viewport is assumed to be estimatedItemSize tall, and // scrolling up through unmeasured content corrects each row's height as it // mounts — the feed visibly jumps. Fixed sizes make the small chrome rows // exact; message rows stay undefined and use LegendList's per-type running - // average once one of their type has been measured. Text-driven heights - // follow the configurable base font size via scaledTypographyLineHeight. - const workingRowHeight = - WORKING_ROW_VERTICAL_EXTRAS + - scaledTypographyLineHeight(MOBILE_TYPOGRAPHY.label, appearance.baseFontSize); + // average once one of their type has been measured. const getFixedItemSize = useCallback( (entry: ThreadFeedEntry) => { switch (entry.type) { @@ -1995,46 +2580,58 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { return TURN_FOLD_HEIGHT; case "work-toggle": return WORK_GROUP_TOGGLE_HEIGHT; - case "working": - return workingRowHeight; case "activity-group": // Expanded rows append a variable detail block — fall back to // measurement for those groups. return entry.activities.some((activity) => expandedWorkRows[activity.id]) ? undefined - : collapsedWorkLogHeight(entry.activities, appearance.baseFontSize); + : collapsedWorkLogHeight(entry.activities); default: return undefined; } }, - [expandedWorkRows, workingRowHeight, appearance.baseFontSize], + [expandedWorkRows], ); + // Disclosures can mount existing offscreen rows as well as new work rows. + // Fade those in after movement; never retain removed rows over replacements. const renderItem = useCallback( - (info: { item: ThreadFeedEntry; index: number }) => - renderFeedEntry(info, { - environmentId: props.environmentId, - copiedRowId, - expandedWorkRows, - terminalAssistantMessageIds, - unsettledTurnId, - onCopyWorkRow, - onToggleWorkGroup, - onToggleWorkRow, - onToggleTurnFold, - onPressImage, - onMarkdownLinkPress, - renderMarkdownImage, - iconSubtleColor, - userBubbleColor, - markdownStyles, - reviewCommentColors, - reviewCommentBubbleWidth, - userBubbleMaxWidth, - skills: props.skills, - }), + (info: { item: ThreadFeedEntry; index: number }) => ( + + + {renderFeedEntry(info, { + environmentId: props.environmentId, + copiedRowId, + expandedWorkRows, + terminalAssistantMessageIds, + unsettledTurnId, + onCopyWorkRow, + onToggleWorkGroup, + onToggleWorkRow, + onToggleTurnFold, + onPressPreview, + onPressVideo, + onMarkdownLinkPress, + renderMarkdownImage, + renderViewedImage, + iconSubtleColor, + userBubbleColor, + markdownStyles, + reviewCommentColors, + reviewCommentBubbleWidth, + userBubbleMaxWidth, + skills: props.skills, + onUseArtifactTemplate: props.onUseArtifactTemplate, + })} + + + ), [ copiedRowId, + disclosureToggleSettling, expandedWorkRows, terminalAssistantMessageIds, unsettledTurnId, @@ -2046,13 +2643,16 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { userBubbleMaxWidth, onCopyWorkRow, onMarkdownLinkPress, - onPressImage, + onPressPreview, + onPressVideo, onToggleTurnFold, onToggleWorkGroup, onToggleWorkRow, props.environmentId, + props.onUseArtifactTemplate, props.skills, renderMarkdownImage, + renderViewedImage, ], ); @@ -2101,7 +2701,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { } : { scrollIndicatorInsets: { top: topContentInset, bottom: 0 } })} {...(anchoredEndSpace ? { anchoredEndSpace } : {})} - // Patched LegendList prop (patches/@legendapp__list@3.2.0.patch): + // Patched LegendList prop (patches/@legendapp__list@3.3.5.patch): // lets its scroll math clamp programmatic scrolls to -headerInset // instead of 0, so initialScrollAtEnd/maintainScrollAtEnd on short // content rest below the transparent header rather than at frame top. @@ -2151,11 +2751,14 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { data={presentedFeed} extraData={listAppearanceData} renderItem={renderItem} + viewabilityConfig={THREAD_MEDIA_VIEWABILITY_CONFIG} keyExtractor={(entry) => entry.id} getItemType={(entry) => entry.type === "message" ? `message:${entry.message.role}` : entry.type } getFixedItemSize={getFixedItemSize} + itemLayoutAnimation={THREAD_FEED_LAYOUT_TRANSITION} + onItemSizeChanged={handleItemSizeChanged} // Measure rows well before they scroll into view so estimate→actual // corrections land offscreen instead of under the user's finger. drawDistance={500} @@ -2227,23 +2830,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { ) : null} - setExpandedImage(null)} - swipeToCloseEnabled - doubleTapToZoomEnabled - /> + setExpandedVideo(null)} /> + setExpandedFile(null)} /> ); }); diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index 6feca0013527..4a4d36c7a211 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -9,10 +9,8 @@ import { import { LegendList } from "@legendapp/list/react-native"; import type { MenuAction } from "@react-native-menu/menu"; import { useAtomValue } from "@effect/atom-react"; -import { AsyncResult } from "effect/unstable/reactivity"; import type { EnvironmentId } from "@t3tools/contracts"; import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort"; -import type { ChangeRequestSettleSource } from "@t3tools/client-runtime/state/thread-settled"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { LayoutChangeEvent } from "react-native"; import { Platform, Pressable, StyleSheet, TextInput, View } from "react-native"; @@ -28,9 +26,7 @@ import { SymbolView } from "../../components/AppSymbol"; import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; import { NativeStackScreenOptions } from "../../native/StackHeader"; import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities"; -import { useThemeColor } from "../../lib/useThemeColor"; import { useProjects, useThreadShells } from "../../state/entities"; -import { mobilePreferencesAtom } from "../../state/preferences"; import { useThreadSearch } from "../../state/queries"; import { useThreadListV2Enabled } from "./use-thread-list-v2-enabled"; import { useThreadListV2ShelfPreferences } from "./use-thread-list-v2-shelf-preferences"; @@ -127,19 +123,11 @@ export function ThreadNavigationSidebar(props: ThreadNavigationSidebarProps) { } function NativeSidebarContainer(props: ThreadNavigationSidebarProps) { - const backgroundColor = useThemeColor("--color-drawer"); - const borderColor = useThemeColor("--color-border"); - return ( @@ -173,10 +161,6 @@ function ThreadNavigationSidebarPane( regenerateThreadTitle, } = useThreadListActions(); const threadListV2Enabled = useThreadListV2Enabled(); - const preferencesResult = useAtomValue(mobilePreferencesAtom); - const autoSettleOnMerge = - !AsyncResult.isSuccess(preferencesResult) || - preferencesResult.value.autoSettleOnMerge !== false; const pendingTasks = usePendingNewTasks(); const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions(); const environments = useMemo( @@ -374,32 +358,6 @@ function ThreadNavigationSidebarPane( // Thread List v2 (beta) support — same model as the compact Home list // (HomeScreen.tsx): flat creation-order card block + settled recency tail. - // PR states stream in per-row. The next partition applies the configured - // merge rule and the always-on close rule. - const [changeRequestByKey, setChangeRequestByKey] = useState< - ReadonlyMap - >(() => new Map()); - const handleChangeRequestState = useCallback( - (threadKey: string, changeRequest: ChangeRequestSettleSource | null) => { - setChangeRequestByKey((current) => { - const existing = current.get(threadKey) ?? null; - if ( - (existing?.state ?? null) === (changeRequest?.state ?? null) && - (existing?.updatedAt ?? null) === (changeRequest?.updatedAt ?? null) - ) { - return current; - } - const next = new Map(current); - if (changeRequest === null) { - next.delete(threadKey); - } else { - next.set(threadKey, changeRequest); - } - return next; - }); - }, - [], - ); // The settled tail renders in pages; expansion resets when the filter // context changes so environment/search flips never inherit a deep page. const [settledVisibleCount, setSettledVisibleCount] = useState( @@ -422,9 +380,7 @@ function ThreadNavigationSidebarPane( toggleSettledShelf, toggleSnoozedShelf, } = useThreadListV2ShelfPreferences(); - // now ticks per minute so the inactivity auto-settle boundary is actually - // crossed while the pane stays open; without a clock dependency the - // partition memoizes a frozen "now". + // The queued-start and snooze helpers need a clock while the pane stays open. const [nowMinute, setNowMinute] = useState(() => new Date().toISOString().slice(0, 16)); // Snooze wake times are second-precise; a counter bumped exactly at the // next wake boundary re-runs the partition with a fresh clock so a woken @@ -432,9 +388,7 @@ function ThreadNavigationSidebarPane( const [snoozeWakeTick, bumpSnoozeWakeTick] = useState(0); useEffect(() => { if (!threadListV2Enabled) return; - // Refresh immediately on enable: the mount-time value can be hours old - // by the time the beta is switched on, which would misclassify the - // inactivity auto-settle boundary until the first tick. + // Refresh immediately because the mount-time value can be hours old. setNowMinute(new Date().toISOString().slice(0, 16)); const id = setInterval(() => setNowMinute(new Date().toISOString().slice(0, 16)), 60_000); return () => clearInterval(id); @@ -517,20 +471,15 @@ function ThreadNavigationSidebarPane( projectRefs: selectedProjectScope === null ? null : selectedProjectScope.projectRefs, searchQuery: props.searchQuery, matchedThreadKeys, - changeRequestByKey, - autoSettleOnMerge, settlementEnvironmentIds, snoozeEnvironmentIds, settledLimit: settledVisibleCount, - now: `${nowMinute}:00.000Z`, - snoozeNow: new Date().toISOString(), + now: new Date().toISOString(), snoozedShelfExpanded, settledShelfExpanded, selectedThreadKey: props.selectedThreadKey ?? null, }); }, [ - changeRequestByKey, - autoSettleOnMerge, nowMinute, snoozeWakeTick, snoozedShelfExpanded, @@ -730,10 +679,6 @@ function ThreadNavigationSidebarPane( ], ); - const backgroundColor = useThemeColor("--color-drawer"); - const borderColor = useThemeColor("--color-border"); - const mutedColor = useThemeColor("--color-foreground-muted"); - const placeholderColor = useThemeColor("--color-placeholder"); const [measuredHeaderHeight, setMeasuredHeaderHeight] = useState(null); // The sticky header (title row, search field, optional connection status) // is measured so the list inset always matches its real height — no @@ -943,7 +888,6 @@ function ThreadNavigationSidebarPane( onPinThread={pinThread} onUnpinThread={unpinThread} onMovePinnedThread={movePinnedThread} - onChangeRequestState={handleChangeRequestState} projectCwd={projectCwdByKey.get(scopeKey) ?? null} onSwipeableClose={handleSwipeableClose} onSwipeableWillOpen={handleSwipeableWillOpen} @@ -1070,7 +1014,6 @@ function ThreadNavigationSidebarPane( arrangedPinnedKeys, confirmDeletePendingTask, confirmDeleteThread, - handleChangeRequestState, handleSelectThread, handleSwipeableClose, handleSwipeableWillOpen, @@ -1170,12 +1113,14 @@ function ThreadNavigationSidebarPane( return ( <> @@ -1285,14 +1225,11 @@ function ThreadNavigationSidebarPane( {/* Title slot doubles as the connection status surface: while an @@ -1317,7 +1254,12 @@ function ThreadNavigationSidebarPane( - + - - {props.option.label} - - {props.option.isDefault ? ( - - Default - - ) : null} - {props.option.isLegacy ? ( - - Legacy + + + + {props.option.label} + + {props.option.isDefault ? ( + + Default + + ) : null} + {props.option.isLegacy ? ( + + Legacy + + ) : null} - ) : null} - + {props.option.subtitle ? ( + + {props.option.subtitle} + + ) : null} + {props.selected ? ( @@ -140,7 +157,6 @@ function ProviderHeader(props: { readonly modelCount: number; readonly onToggle: () => void; }) { - const iconSubtle = useThemeColor("--color-icon-subtle"); const content = ( <> @@ -156,7 +172,7 @@ function ProviderHeader(props: { @@ -192,7 +208,6 @@ function DisclosureRow(props: { readonly onPress: () => void; readonly isLast?: boolean; }) { - const iconSubtle = useThemeColor("--color-icon-subtle"); return ( ) : null} - + ); } @@ -222,7 +242,6 @@ function ChoiceRow(props: { readonly onPress: () => void; readonly isLast: boolean; }) { - const checkmarkColor = useThemeColor("--color-icon"); return ( @@ -281,6 +300,7 @@ type ThreadSettingsSubmenuPage = | { readonly kind: "runtime" }; type ThreadSettingsSessionProps = { + readonly environmentId: EnvironmentId | null; readonly providerGroups: ReadonlyArray; readonly selectedModel: ModelSelection | null; readonly onSelectModel: (option: ModelOption) => void; @@ -332,6 +352,7 @@ export function useExistingThreadSettingsRoutePresentation() { } type ThreadSettingsSessionValue = { + readonly environmentId: EnvironmentId | null; readonly providerGroups: ReadonlyArray; readonly runtimeMode: RuntimeMode; readonly onUpdateRuntimeMode: (mode: RuntimeMode) => void; @@ -450,6 +471,7 @@ function ThreadSettingsSessionProvider( const value = useMemo( () => ({ + environmentId: props.environmentId, providerGroups: props.providerGroups, runtimeMode: props.runtimeMode, onUpdateRuntimeMode: props.onUpdateRuntimeMode, @@ -478,6 +500,7 @@ function ThreadSettingsSessionProvider( hasLegacyModels, isApplied, isDisplayed, + props.environmentId, pendingModel, pressModel, providerFilter, @@ -946,6 +969,23 @@ function ThreadSettingsModelsScreen() { const navigation = useNavigation>(); const usesNativeMailSearchToolbar = Platform.OS === "ios" && NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED; const hasCustomCatalogFilter = session.providerFilter !== null || session.showLegacy; + const refreshProvidersCommand = useAtomCommand(serverEnvironment.refreshProviders, { + reportFailure: false, + }); + const refreshProviderCatalog = useMemo( + () => createProviderCatalogRefreshRunner(refreshProvidersCommand), + [refreshProvidersCommand], + ); + const [isRefreshingProviders, setIsRefreshingProviders] = useState(false); + const refreshProviders = useCallback(() => { + if (!session.environmentId || isRefreshingProviders) return; + setIsRefreshingProviders(true); + void refreshProviderCatalog(session.environmentId).then((result) => { + setIsRefreshingProviders(false); + const error = providerCatalogRefreshError(result); + if (error) Alert.alert("Could not refresh models", error); + }); + }, [isRefreshingProviders, refreshProviderCatalog, session.environmentId]); const commitAndClose = useCallback(() => { session.commitPendingModel(); presentation.onClose(); @@ -993,6 +1033,12 @@ function ThreadSettingsModelsScreen() { {Platform.OS === "android" ? ( + ({ onClose: props.onClose, @@ -1217,6 +1271,7 @@ export function NewTaskThreadSettingsRouteScreen() { return ( flow.setSelectedModelKey(option.key, option.selection.options)} diff --git a/apps/mobile/src/features/threads/floating-working-control.tsx b/apps/mobile/src/features/threads/floating-working-control.tsx new file mode 100644 index 000000000000..bdfa19a9eeaf --- /dev/null +++ b/apps/mobile/src/features/threads/floating-working-control.tsx @@ -0,0 +1,208 @@ +import { GlassContainer, GlassView } from "expo-glass-effect"; +import { useEffect, useState } from "react"; +import { Text as SystemText, View } from "react-native"; +import Animated, { + Easing, + FadeIn, + FadeOut, + ReduceMotion, + useAnimatedStyle, + useSharedValue, + withTiming, +} from "react-native-reanimated"; +import { withUniwind } from "uniwind"; + +import { AppText as Text } from "../../components/AppText"; +import { ControlPill } from "../../components/ControlPill"; +import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; + +const CONTROL_HEIGHT = 44; +const CONTROL_COMPOSER_GAP = 8; +const GLASS_MERGE_SPACING = 12; +const CONTROL_ENTERING = FadeIn.duration(180).reduceMotion(ReduceMotion.System); +const CONTROL_EXITING = FadeOut.duration(120).reduceMotion(ReduceMotion.System); +const CONTROL_TIMING = { + duration: 240, + easing: Easing.out(Easing.cubic), + reduceMotion: ReduceMotion.System, +} as const; +const CONTROL_SEPARATION = (16 + CONTROL_HEIGHT) / 2; + +// Expo reapplies glass after native layout and window reattachment, when UIKit +// can otherwise leave the label visible but lose the material behind it. +const UniwindGlassView = withUniwind(GlassView, { + style: { fromClassName: "className" }, +}); +const UniwindGlassContainer = withUniwind(GlassContainer, { + style: { fromClassName: "className" }, +}); +const AnimatedGlassView = Animated.createAnimatedComponent(UniwindGlassView); + +export const FLOATING_WORKING_CONTROL_COVERAGE = CONTROL_HEIGHT + CONTROL_COMPOSER_GAP; + +export function FloatingWorkingControl(props: { + readonly colorScheme: "light" | "dark"; + readonly startedAt: string | null; + readonly showScrollToEnd: boolean; + readonly onScrollToEnd: () => void; +}) { + const separationProgress = useSharedValue(props.showScrollToEnd ? 1 : 0); + + useEffect(() => { + separationProgress.value = withTiming(props.showScrollToEnd ? 1 : 0, CONTROL_TIMING); + }, [props.showScrollToEnd, separationProgress]); + + const timerStyle = useAnimatedStyle(() => ({ + transform: [{ translateX: CONTROL_SEPARATION * (1 - separationProgress.value) }], + })); + const arrowTransformStyle = useAnimatedStyle(() => ({ + transform: [{ translateX: -CONTROL_SEPARATION * (1 - separationProgress.value) }], + })); + const arrowContentStyle = useAnimatedStyle(() => ({ + opacity: separationProgress.value, + })); + + if (props.startedAt === null && !props.showScrollToEnd) { + return null; + } + + return ( + + {props.startedAt !== null && NATIVE_LIQUID_GLASS_SUPPORTED ? ( + + + + + + + + + + + + ) : props.startedAt !== null ? ( + + + + + + + + + + ) : NATIVE_LIQUID_GLASS_SUPPORTED ? ( + + + + ) : ( + + )} + + ); +} + +function WorkingDuration(props: { readonly startedAt: string }) { + const [nowMs, setNowMs] = useState(() => Date.now()); + + useEffect(() => { + setNowMs(Date.now()); + const intervalId = setInterval(() => setNowMs(Date.now()), 1_000); + return () => clearInterval(intervalId); + }, [props.startedAt]); + + const duration = formatWorkingDuration(props.startedAt, nowMs); + const label = `Working for ${duration}`; + + return ( + + Working for + + {duration} + + + ); +} + +function formatWorkingDuration(startedAt: string, nowMs: number): string { + const startedAtMs = Date.parse(startedAt); + if (!Number.isFinite(startedAtMs) || nowMs <= startedAtMs) { + return "0s"; + } + + const totalSeconds = Math.floor((nowMs - startedAtMs) / 1_000); + if (totalSeconds < 60) { + return `${totalSeconds}s`; + } + + const minutes = Math.floor(totalSeconds / 60); + const seconds = String(totalSeconds % 60).padStart(2, "0"); + return `${minutes}m ${seconds}s`; +} + +function ScrollToEndButton(props: { readonly disabled?: boolean; readonly onPress: () => void }) { + return ( + + ); +} diff --git a/apps/mobile/src/features/threads/git/GitCommitSheet.tsx b/apps/mobile/src/features/threads/git/GitCommitSheet.tsx index cdc7f1a64a9a..f263372bad22 100644 --- a/apps/mobile/src/features/threads/git/GitCommitSheet.tsx +++ b/apps/mobile/src/features/threads/git/GitCommitSheet.tsx @@ -85,7 +85,7 @@ export function GitCommitSheet(_props: GitCommitSheetProps) { {isDefaultRef ? ( - + Warning: this is the default branch. ) : null} diff --git a/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx b/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx index 17e4de0ab6fa..5aefccb4baff 100644 --- a/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx +++ b/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx @@ -17,7 +17,7 @@ import { Alert, Platform, Pressable, RefreshControl, ScrollView, View } from "re import { Screen, ScreenStack, ScreenStackHeaderConfig } from "react-native-screens"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { useThemeColor } from "../../../lib/useThemeColor"; +import { useUniwindTheme } from "../../../lib/useUniwindTheme"; import { AndroidSheetHeader } from "../../../components/AndroidScreenHeader"; import { AppText as Text } from "../../../components/AppText"; @@ -53,10 +53,9 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) { const { selectedThreadCwd, selectedThreadWorktreePath } = useSelectedThreadWorktree(); const gitState = useSelectedThreadGitState(); const gitActions = useSelectedThreadGitActions(); - - const iconColor = useThemeColor("--color-icon"); - const foregroundColor = String(useThemeColor("--color-foreground")); - const sheetColor = String(useThemeColor("--color-sheet")); + const theme = useUniwindTheme(); + const foregroundColor = theme["--color-foreground"]; + const sheetColor = theme["--color-sheet"]; const gitStatus = useEnvironmentQuery( selectedThread !== null && selectedThreadCwd !== null @@ -385,7 +384,7 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) { diff --git a/apps/mobile/src/features/threads/git/gitSheetComponents.tsx b/apps/mobile/src/features/threads/git/gitSheetComponents.tsx index 61346fcef0fd..285c3414a9e9 100644 --- a/apps/mobile/src/features/threads/git/gitSheetComponents.tsx +++ b/apps/mobile/src/features/threads/git/gitSheetComponents.tsx @@ -1,7 +1,6 @@ import { SymbolView } from "../../../components/AppSymbol"; import type { ComponentProps } from "react"; import { Pressable, View } from "react-native"; -import { useThemeColor } from "../../../lib/useThemeColor"; import { AppText as Text } from "../../../components/AppText"; import { cn } from "../../../lib/cn"; @@ -14,12 +13,13 @@ export function SheetActionButton(props: { readonly tone?: "primary" | "secondary" | "danger"; readonly onPress: () => void; }) { - const primaryFg = useThemeColor("--color-primary-foreground"); - const dangerFg = useThemeColor("--color-danger-foreground"); - const secondaryFg = useThemeColor("--color-secondary-foreground"); - const tone = props.tone ?? "secondary"; - const textColor = tone === "primary" ? primaryFg : tone === "danger" ? dangerFg : secondaryFg; + const textColorClassName = + tone === "primary" + ? "accent-primary-foreground" + : tone === "danger" + ? "accent-danger-foreground" + : "accent-secondary-foreground"; return ( - + void; }) { - const iconColor = useThemeColor("--color-icon"); - const iconSubtleColor = useThemeColor("--color-icon-subtle"); - return ( - + {props.title} @@ -89,7 +96,12 @@ export function SheetListRow(props: { {props.subtitle} ) : null} - + ); } diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index 14f0fcc95a22..792bb143a834 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -7,7 +7,7 @@ import type { ProviderInteractionMode, ProviderOptionSelection, RuntimeMode, - ServerProviderSkill, + ServerProvider, } from "@t3tools/contracts"; import { CommandId, @@ -27,12 +27,13 @@ import { pipe } from "effect/Function"; import { useEnvironmentServerConfig, useProjects, useThreadShells } from "../../state/entities"; import type { TurnCommandMetadata } from "../../lib/commandMetadata"; -import type { DraftComposerImageAttachment } from "../../lib/composerImages"; +import type { DraftComposerAttachment } from "../../lib/composerImages"; import type { ModelOption, ProviderGroup } from "../../lib/modelOptions"; import { buildModelOptions, groupByProvider, resolveDefaultableModelSelection, + resolveNewTaskModelSelection, resolveSelectableModelSelection, } from "../../lib/modelOptions"; import { scopedProjectKey } from "../../lib/scopedEntities"; @@ -47,16 +48,22 @@ import { isComposerDraftEmpty, removeComposerDraftAttachment, replaceComposerDraftAttachments, + scheduleUnusedComposerAttachmentCleanup, setComposerDraftText, + setStickyComposerModelSelection, updateComposerDraftSettings, useComposerDraft, + useStickyComposerModelSelection, } from "../../state/use-composer-drafts"; +import { + capturePendingTaskEditorWriteBaseline, + flushPendingTaskEditorWrite, +} from "../../state/pending-task-editor-writes"; import { useDebouncedValue, usePaginatedBranches } from "../../state/queries"; import { vcsEnvironment } from "../../state/vcs"; import { flattenQueuedThreadMessages, threadOutboxManager, - updateThreadOutboxMessage, type QueuedThreadMessage, } from "../../state/thread-outbox"; import { @@ -132,7 +139,7 @@ type NewTaskFlowContextValue = { readonly draftKey: string | null; readonly editingPendingTask: QueuedThreadMessage | null; readonly prompt: string; - readonly attachments: ReadonlyArray; + readonly attachments: ReadonlyArray; readonly submitting: boolean; readonly branchQuery: string; readonly branchesLoading: boolean; @@ -153,7 +160,7 @@ type NewTaskFlowContextValue = { readonly modelOptions: ReadonlyArray; readonly selectedModel: ModelSelection | null; readonly selectedModelOption: ModelOption | null; - readonly selectedProviderSkills: ReadonlyArray; + readonly selectedProviderStatus: ServerProvider | null; readonly providerGroups: ReadonlyArray; readonly filteredBranches: ReadonlyArray; readonly reset: () => void; @@ -171,8 +178,9 @@ type NewTaskFlowContextValue = { readonly cancelEditingPendingTask: () => void; readonly buildPendingTaskMessage: (metadata: TurnCommandMetadata) => QueuedThreadMessage | null; readonly setPrompt: (value: string) => void; - readonly replaceAttachments: (attachments: ReadonlyArray) => void; - readonly appendAttachments: (attachments: ReadonlyArray) => void; + readonly replaceAttachments: (attachments: ReadonlyArray) => void; + /** Appends draft attachments; returns how many the live cap rejected. */ + readonly appendAttachments: (attachments: ReadonlyArray) => number; readonly removeAttachment: (imageId: string) => void; readonly clearAttachments: () => void; readonly setSubmitting: (value: boolean) => void; @@ -227,6 +235,9 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { // Mirrors `editingPendingTask` synchronously so the unmount flush cannot act // on a task whose editing session already ended this render. const editingPendingTaskRef = useRef(null); + // Outbox revision this editor session may write after its predecessor save. + // Unrelated accepted writes still beat the dismissed session's CAS. + const editingRevisionRef = useRef(Promise.resolve(0)); const reset = useCallback(() => { setSelectedEnvironmentId(null); @@ -418,21 +429,33 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { selectedEnvironmentServerConfig, selectedProject?.defaultModelSelection ?? null, ); + const storedStickyModelSelection = useStickyComposerModelSelection(); + const stickyModelSelection = resolveDefaultableModelSelection( + selectedEnvironmentServerConfig, + storedStickyModelSelection, + ); const modelOptions = useMemo( () => buildModelOptions( selectedEnvironmentServerConfig, - draftModelSelection ?? projectDefaultModelSelection, + draftModelSelection ?? projectDefaultModelSelection ?? stickyModelSelection, ), - [selectedEnvironmentServerConfig, draftModelSelection, projectDefaultModelSelection], + [ + selectedEnvironmentServerConfig, + draftModelSelection, + projectDefaultModelSelection, + stickyModelSelection, + ], ); - const selectedModel = - draftModelSelection ?? - projectDefaultModelSelection ?? - modelOptions.find((option) => option.isDefault)?.selection ?? - modelOptions[0]?.selection ?? - null; + // An unsent draft keeps its explicit pick. Fresh drafts resolve the project + // default before the last manual app-wide selection and provider default. + const selectedModel = resolveNewTaskModelSelection({ + draftSelection: draftModelSelection, + projectDefaultSelection: projectDefaultModelSelection, + stickySelection: stickyModelSelection, + modelOptions, + }); const selectedModelKey = selectedModel ? `${selectedModel.instanceId}:${selectedModel.model}` : null; @@ -444,11 +467,11 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { option.selection.instanceId === selectedModel.instanceId && option.selection.model === selectedModel.model, ) ?? null; - const selectedProviderSkills = useMemo( + const selectedProviderStatus = useMemo( () => selectedEnvironmentServerConfig?.providers.find( (provider) => provider.instanceId === selectedModel?.instanceId, - )?.skills ?? [], + ) ?? null, [selectedEnvironmentServerConfig, selectedModel?.instanceId], ); const setSelectedModelKey = useCallback( @@ -462,9 +485,9 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { if (!option) { return; } - updateComposerDraftSettings(selectedProjectDraftKey, { - modelSelection: options ? { ...option.selection, options } : option.selection, - }); + const selection = options ? { ...option.selection, options } : option.selection; + updateComposerDraftSettings(selectedProjectDraftKey, { modelSelection: selection }); + setStickyComposerModelSelection(selection); }, [modelOptions, selectedProjectDraftKey], ); @@ -482,6 +505,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { updateComposerDraftSettings(selectedProjectDraftKey, { modelSelection: nextSelection, }); + setStickyComposerModelSelection(nextSelection); }, [selectedModel, selectedProjectDraftKey], ); @@ -497,7 +521,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { [selectedProjectDraftKey], ); const replaceAttachments = useCallback( - (nextAttachments: ReadonlyArray) => { + (nextAttachments: ReadonlyArray) => { if (!selectedProjectDraftKey) { return; } @@ -505,12 +529,14 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { }, [selectedProjectDraftKey], ); + // Returns how many attachments the live cap rejected so the caller can + // tell the user (a concurrent add can fill the draft mid-pick). const appendAttachments = useCallback( - (nextAttachments: ReadonlyArray) => { + (nextAttachments: ReadonlyArray): number => { if (!selectedProjectDraftKey) { - return; + return 0; } - appendComposerDraftAttachments(selectedProjectDraftKey, nextAttachments); + return appendComposerDraftAttachments(selectedProjectDraftKey, nextAttachments); }, [selectedProjectDraftKey], ); @@ -819,6 +845,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { setSelectedProjectKey(scopedProjectKey(message.environmentId, message.creation.projectId)); activeEditingMessageId = message.messageId; editingPendingTaskRef.current = message; + editingRevisionRef.current = capturePendingTaskEditorWriteBaseline(message.messageId); setEditingPendingTask(message); // Hold the outbox drain off this task while it is open in the editor. holdEditingQueuedMessage(message.messageId); @@ -916,6 +943,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { } clearComposerDraft(pendingTaskDraftKey(editing.messageId)); releaseEditingQueuedMessage(editing.messageId); + scheduleUnusedComposerAttachmentCleanup(editing.attachments); } setEditingPendingTask(null); }, []); @@ -968,17 +996,28 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { return; } - // update() rewrites the task only if it is still queued — a concurrent - // delete or delivery wins, so the flush cannot resurrect it. - void updateThreadOutboxMessage(message) - .then(() => { + // The write handoff lets a reopened editor follow this editor's pending + // save. Its CAS still rejects unrelated queue edits, deletes, and + // deliveries, so the flush cannot resurrect or overwrite them. + void flushPendingTaskEditorWrite({ + message, + baseline: editingRevisionRef.current, + draftKey: pendingTaskDraftKey(editing.messageId), + }) + .then((savedDraftStillCurrent) => { // If this task was reopened (possibly in a fresh provider) while // the save was in flight, that session owns the draft and the lock. if (activeEditingMessageId === editing.messageId) { return; } + if (!savedDraftStillCurrent) { + // A newer queue write won the CAS, or a newer editor changed this + // draft. Keep the draft and drain lock so reopening can retry it. + return; + } clearComposerDraft(pendingTaskDraftKey(editing.messageId)); releaseEditingQueuedMessage(editing.messageId); + scheduleUnusedComposerAttachmentCleanup(editing.attachments); }) .catch((error) => { // Keep the drain lock and the draft: delivering the stale payload @@ -1028,7 +1067,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { modelOptions, selectedModel, selectedModelOption, - selectedProviderSkills, + selectedProviderStatus, providerGroups, filteredBranches, reset, @@ -1090,7 +1129,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { selectedModelKey, selectedModelOption, selectedProjectDraftKey, - selectedProviderSkills, + selectedProviderStatus, setSelectedModelOptions, selectedProject, selectedProjectKey, diff --git a/apps/mobile/src/features/threads/provider-catalog-refresh.test.ts b/apps/mobile/src/features/threads/provider-catalog-refresh.test.ts new file mode 100644 index 000000000000..565a54074400 --- /dev/null +++ b/apps/mobile/src/features/threads/provider-catalog-refresh.test.ts @@ -0,0 +1,46 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import { AsyncResult } from "effect/unstable/reactivity"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { + createProviderCatalogRefreshRunner, + providerCatalogRefreshError, +} from "./provider-catalog-refresh"; + +describe("mobile provider catalog refresh", () => { + it("calls server discovery for the selected environment and deduplicates pending taps", async () => { + let resolveRefresh: ((value: "refreshed") => void) | undefined; + const refreshProviders = vi.fn( + () => + new Promise<"refreshed">((resolve) => { + resolveRefresh = resolve; + }), + ); + const refresh = createProviderCatalogRefreshRunner(refreshProviders); + const environmentId = EnvironmentId.make("environment-mobile"); + + const first = refresh(environmentId); + const second = refresh(environmentId); + + expect(second).toBe(first); + expect(refreshProviders).toHaveBeenCalledOnce(); + expect(refreshProviders).toHaveBeenCalledWith({ environmentId, input: {} }); + + resolveRefresh?.("refreshed"); + await expect(first).resolves.toBe("refreshed"); + }); + + it("reports a discovery error and allows retry after the failed command settles", async () => { + const failure = AsyncResult.failure(Cause.fail(new Error("discovery failed"))); + const success = AsyncResult.success("refreshed"); + let callCount = 0; + const refreshProviders = vi.fn(async () => (callCount++ === 0 ? failure : success)); + const refresh = createProviderCatalogRefreshRunner(refreshProviders); + const environmentId = EnvironmentId.make("environment-mobile"); + + expect(providerCatalogRefreshError(await refresh(environmentId))).toBe("discovery failed"); + expect(providerCatalogRefreshError(await refresh(environmentId))).toBeNull(); + expect(refreshProviders).toHaveBeenCalledTimes(2); + }); +}); diff --git a/apps/mobile/src/features/threads/provider-catalog-refresh.ts b/apps/mobile/src/features/threads/provider-catalog-refresh.ts new file mode 100644 index 000000000000..3e79a5b1c7b5 --- /dev/null +++ b/apps/mobile/src/features/threads/provider-catalog-refresh.ts @@ -0,0 +1,34 @@ +import type { EnvironmentId } from "@t3tools/contracts"; +import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; + +type RefreshProvidersTarget = { + readonly environmentId: EnvironmentId; + readonly input: Record; +}; + +/** Deduplicates taps while the server refresh command is still running. */ +export function createProviderCatalogRefreshRunner( + refreshProviders: (target: RefreshProvidersTarget) => Promise, +) { + let pending: Promise | null = null; + + return (environmentId: EnvironmentId): Promise => { + if (pending) return pending; + pending = refreshProviders({ environmentId, input: {} }).finally(() => { + pending = null; + }); + return pending; + }; +} + +export function providerCatalogRefreshError( + result: AtomCommandResult, +): string | null { + if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) return null; + const error = squashAtomCommandFailure(result); + return error instanceof Error ? error.message : "Provider discovery failed."; +} diff --git a/apps/mobile/src/features/threads/sidebar-filter-button.tsx b/apps/mobile/src/features/threads/sidebar-filter-button.tsx index 1895ef0d45ca..61ab1fb41fba 100644 --- a/apps/mobile/src/features/threads/sidebar-filter-button.tsx +++ b/apps/mobile/src/features/threads/sidebar-filter-button.tsx @@ -1,8 +1,6 @@ import { SymbolView } from "../../components/AppSymbol"; import { Pressable } from "react-native"; -import { useThemeColor } from "../../lib/useThemeColor"; - export type SidebarFilterButtonIcon = | "line.3.horizontal.decrease.circle" | "line.3.horizontal.decrease.circle.fill"; @@ -11,8 +9,6 @@ export function SidebarFilterButton(props: { readonly accessibilityLabel: string; readonly icon: SidebarFilterButtonIcon; }) { - const iconColor = useThemeColor("--color-foreground"); - return ( - + ); } diff --git a/apps/mobile/src/features/threads/sidebar-header-actions.tsx b/apps/mobile/src/features/threads/sidebar-header-actions.tsx index 9ce77f8991bd..52fb8c699981 100644 --- a/apps/mobile/src/features/threads/sidebar-header-actions.tsx +++ b/apps/mobile/src/features/threads/sidebar-header-actions.tsx @@ -1,8 +1,6 @@ import { SymbolView } from "../../components/AppSymbol"; import { Pressable, View } from "react-native"; -import { useThemeColor } from "../../lib/useThemeColor"; - export interface SidebarHeaderActionsProps { readonly onOpenSettings: () => void; } @@ -12,8 +10,6 @@ function FallbackHeaderButton(props: { readonly icon: "gearshape" | "square.and.pencil"; readonly onPress: () => void; }) { - const iconColor = useThemeColor("--color-foreground"); - return ( - + ); } diff --git a/apps/mobile/src/features/threads/sidebar-navigation-shell.tsx b/apps/mobile/src/features/threads/sidebar-navigation-shell.tsx index d5e09b07e1e8..e9e204e47770 100644 --- a/apps/mobile/src/features/threads/sidebar-navigation-shell.tsx +++ b/apps/mobile/src/features/threads/sidebar-navigation-shell.tsx @@ -10,7 +10,6 @@ import { getCompactBrandHeaderOptions } from "../../components/CompactBrandTitle import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; import { nativeHeaderScrollEdgeEffects } from "../../native/StackHeader"; import { useMobileNavigationTheme } from "../../lib/useMobileNavigationTheme"; -import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; const SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version); @@ -53,8 +52,7 @@ const SidebarStack = createNativeStackNavigator(); * navigation hooks used for header configuration inside the pane. */ export function SidebarNavigationShell(props: { readonly children: ReactNode }) { - const { themeAppearance } = useAppearancePreferences(); - const navigationTheme = useMobileNavigationTheme(themeAppearance); + const navigationTheme = useMobileNavigationTheme(); return ( diff --git a/apps/mobile/src/features/threads/thread-feed-live-follow.test.ts b/apps/mobile/src/features/threads/thread-feed-live-follow.test.ts index 2ea207923429..b77600805876 100644 --- a/apps/mobile/src/features/threads/thread-feed-live-follow.test.ts +++ b/apps/mobile/src/features/threads/thread-feed-live-follow.test.ts @@ -102,6 +102,17 @@ describe("resolveThreadFeedLiveFollow", () => { ).toBe(false); }); + it.each([ + { isAtEnd: false, userScrollSessionActive: false, expected: false }, + { isAtEnd: true, userScrollSessionActive: false, expected: true }, + { isAtEnd: false, userScrollSessionActive: true, expected: false }, + { isAtEnd: true, userScrollSessionActive: true, expected: false }, + ])("reconciles follow after a disclosure settles: %j", ({ expected, ...state }) => { + expect(resolveThreadFeedLiveFollow(!expected, { type: "disclosure-settled", ...state })).toBe( + expected, + ); + }); + it("re-arms at the actual end only after the user scroll session ends", () => { expect( resolveThreadFeedLiveFollow(false, { diff --git a/apps/mobile/src/features/threads/thread-feed-live-follow.ts b/apps/mobile/src/features/threads/thread-feed-live-follow.ts index 312fd67473e5..83d5cc22faed 100644 --- a/apps/mobile/src/features/threads/thread-feed-live-follow.ts +++ b/apps/mobile/src/features/threads/thread-feed-live-follow.ts @@ -7,7 +7,7 @@ export type ThreadFeedLiveFollowEvent = readonly userScrollSessionActive: boolean; } | { - readonly type: "scroll"; + readonly type: "scroll" | "disclosure-settled"; readonly isAtEnd: boolean; readonly userScrollSessionActive: boolean; }; @@ -41,6 +41,8 @@ export function resolveThreadFeedLiveFollow( return false; case "user-scroll-end": return event.userScrollSessionActive ? event.isAtEnd : current; + case "disclosure-settled": + return !event.userScrollSessionActive && event.isAtEnd; case "scroll": if (event.userScrollSessionActive) { return false; diff --git a/apps/mobile/src/features/threads/thread-list-items.tsx b/apps/mobile/src/features/threads/thread-list-items.tsx index 78e6e43c075d..df10e585aaad 100644 --- a/apps/mobile/src/features/threads/thread-list-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-items.tsx @@ -19,7 +19,7 @@ import { cn } from "../../lib/cn"; import { HOME_HORIZONTAL_INSET } from "../../lib/layoutMetrics"; import { relativeTime } from "../../lib/time"; import { themeColorWithAlpha } from "../../lib/mobileTheme"; -import { useThemeColor } from "../../lib/useThemeColor"; +import { useUniwindTheme } from "../../lib/useUniwindTheme"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; import { useThreadPr, type ThreadPr } from "../../state/use-thread-pr"; import type { HomeGroupDisplayAction } from "../home/homeListItems"; @@ -87,7 +87,6 @@ export const ThreadListGroupHeader = memo(function ThreadListGroupHeader(props: readonly newThreadTarget?: EnvironmentProject | null; readonly onNewThread?: (project: EnvironmentProject) => void; }) { - const iconMutedColor = useThemeColor("--color-icon-muted"); const { groupKey, onGroupAction, onNewThread } = props; const newThreadTarget = props.newThreadTarget ?? null; const compact = props.variant === "compact"; @@ -171,7 +170,7 @@ export const ThreadListGroupHeader = memo(function ThreadListGroupHeader(props: @@ -190,7 +189,6 @@ export const ThreadListShowMoreRow = memo(function ThreadListShowMoreRow(props: readonly groupKey: string; readonly onGroupAction: (key: string, action: HomeGroupDisplayAction) => void; }) { - const iconSubtleColor = useThemeColor("--color-icon-subtle"); const showsMore = props.hiddenCount > 0; const compact = props.variant === "compact"; const { groupKey, onGroupAction } = props; @@ -221,7 +219,7 @@ export const ThreadListShowMoreRow = memo(function ThreadListShowMoreRow(props: @@ -275,10 +273,9 @@ export const PendingTaskListRow = memo(function PendingTaskListRow(props: { readonly onDeletePendingTask: (pendingTask: PendingNewTask) => void; }) { const compact = props.variant === "compact"; - const separatorColor = useThemeColor("--color-separator"); - const iconSubtleColor = useThemeColor("--color-icon-subtle"); - const mutedColor = useThemeColor("--color-foreground-muted"); - const pressedBackgroundColor = useThemeColor("--color-subtle"); + const theme = useUniwindTheme(); + const separatorColor = theme["--color-separator"]; + const pressedBackgroundColor = theme["--color-subtle"]; const { pendingTask, onSelectPendingTask, onDeletePendingTask } = props; const timestamp = relativeTime(pendingTask.message.createdAt); @@ -294,8 +291,8 @@ export const PendingTaskListRow = memo(function PendingTaskListRow(props: { ); const statusPill = ( - - Pending + + Pending ); @@ -305,7 +302,7 @@ export const PendingTaskListRow = memo(function PendingTaskListRow(props: { @@ -446,13 +443,13 @@ export const ThreadListRow = memo(function ThreadListRow(props: { // thread, so a hover highlight can't leak across rows. const [hovered, setHovered] = useRecyclingState(false); - const separatorColor = useThemeColor("--color-separator"); - const iconSubtleColor = useThemeColor("--color-icon-subtle"); - const screenColor = useThemeColor("--color-screen"); - const drawerColor = useThemeColor("--color-drawer"); - const pressedBackgroundColor = useThemeColor("--color-subtle"); - const selectedBackgroundColor = useThemeColor("--color-user-bubble"); - const selectedForegroundColor = useThemeColor("--color-user-bubble-foreground"); + const theme = useUniwindTheme(); + const separatorColor = theme["--color-separator"]; + const screenColor = theme["--color-screen"]; + const drawerColor = theme["--color-drawer"]; + const pressedBackgroundColor = theme["--color-subtle"]; + const selectedBackgroundColor = theme["--color-user-bubble"]; + const selectedForegroundColor = theme["--color-user-bubble-foreground"]; const { thread, onSelectThread, onArchiveThread, onDeleteThread, onRegenerateThreadTitle } = props; @@ -600,7 +597,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: { diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index c0322a0336fe..5ea43000bf1b 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -3,11 +3,7 @@ import type { EnvironmentThreadShell, } from "@t3tools/client-runtime/state/shell"; import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search"; -import { - canSnooze, - resolveSnoozePresets, - type ChangeRequestSettleSource, -} from "@t3tools/client-runtime/state/thread-settled"; +import { canSnooze, resolveSnoozePresets } from "@t3tools/client-runtime/state/thread-settled"; import type { MenuAction } from "@react-native-menu/menu"; import { memo, useCallback, useEffect, useMemo, useState, type ComponentProps } from "react"; import { Alert, Platform, Pressable, useWindowDimensions, View } from "react-native"; @@ -20,7 +16,7 @@ import { ProjectFavicon } from "../../components/ProjectFavicon"; import { ProviderIcon } from "../../components/ProviderIcon"; import { cn } from "../../lib/cn"; import { relativeTime } from "../../lib/time"; -import { useThemeColor } from "../../lib/useThemeColor"; +import { useUniwindTheme } from "../../lib/useUniwindTheme"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; import { useThreadPr } from "../../state/use-thread-pr"; import { ThreadSwipeable } from "../home/thread-swipe-actions"; @@ -54,10 +50,10 @@ const MONO_FONT = Platform.select({ const STATUS_LABEL_BY_STATUS: Partial< Record > = { - approval: { label: "Approval", className: "text-amber-700 dark:text-amber-300" }, - input: { label: "Input", className: "text-indigo-600 dark:text-indigo-300" }, - working: { label: "Working", className: "text-sky-600 dark:text-sky-400" }, - failed: { label: "Failed", className: "text-red-700 dark:text-red-300" }, + approval: { label: "Approval", className: "text-adaptive-amber-700-300" }, + input: { label: "Input", className: "text-adaptive-indigo-600-300" }, + working: { label: "Working", className: "text-adaptive-sky-600-400" }, + failed: { label: "Failed", className: "text-adaptive-red-700-300" }, }; function threadTimeLabel(thread: EnvironmentThreadShell): string { @@ -95,7 +91,6 @@ export const ThreadListV2SectionDivider = memo(function ThreadListV2SectionDivid readonly label: string; readonly pane?: "screen" | "sidebar"; }) { - const borderColor = useThemeColor("--color-border"); return ( {props.label} - + ); }); @@ -136,10 +131,10 @@ export const ThreadListV2SnoozedShelfHeader = memo(function ThreadListV2SnoozedS onPress={props.onToggle} style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1 })} > - + {props.expanded ? "Snoozed" : `Snoozed (${props.count})`} - + void; readonly pane?: "screen" | "sidebar"; }) { - const mutedColor = useThemeColor("--color-foreground-muted"); return ( @@ -215,8 +209,9 @@ export const ThreadListV2PendingRow = memo(function ThreadListV2PendingRow(props readonly onDeletePendingTask: (pendingTask: PendingNewTask) => void; }) { const { pendingTask, onSelectPendingTask, onDeletePendingTask } = props; - const drawerColor = useThemeColor("--color-drawer"); - const pressedBackgroundColor = useThemeColor("--color-subtle"); + const theme = useUniwindTheme(); + const drawerColor = theme["--color-drawer"]; + const pressedBackgroundColor = theme["--color-subtle"]; const sidebarPane = props.pane === "sidebar"; const projectTitle = props.projectTitle ?? props.project?.title ?? pendingTask.creation.projectTitle ?? ""; @@ -373,12 +368,6 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { readonly canMovePinnedDown?: boolean; readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void; readonly onSwipeableClose: (methods: SwipeableMethods) => void; - /** Reports this row's live PR (state + last activity) for the partition's - merge and close rules. Mirrors web's onChangeRequestState. */ - readonly onChangeRequestState?: ( - threadKey: string, - changeRequest: ChangeRequestSettleSource | null, - ) => void; readonly projectCwd?: string | null; readonly searchMatch?: EnvironmentThreadSearchMatch; readonly searchQuery?: string; @@ -401,27 +390,17 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { onPinThread, onUnpinThread, onMovePinnedThread, - onChangeRequestState, } = props; const snoozedRow = props.snoozed === true; const pinnedRow = props.pinned === true; const pr = useThreadPr(thread, props.projectCwd ?? props.project?.workspaceRoot ?? null); - const prState = pr?.state ?? null; - const prUpdatedAt = pr?.updatedAt ?? null; - const threadKey = `${thread.environmentId}:${thread.id}`; - useEffect(() => { - onChangeRequestState?.( - threadKey, - prState === null ? null : { state: prState, updatedAt: prUpdatedAt }, - ); - }, [onChangeRequestState, prState, prUpdatedAt, threadKey]); - const screenColor = useThemeColor("--color-screen"); - const drawerColor = useThemeColor("--color-drawer"); - const pressedBackgroundColor = useThemeColor("--color-subtle"); - const selectedBackgroundColor = useThemeColor("--color-user-bubble"); - const pinTintColor = useThemeColor("--color-foreground-muted"); + const theme = useUniwindTheme(); + const screenColor = theme["--color-screen"]; + const drawerColor = theme["--color-drawer"]; + const pressedBackgroundColor = theme["--color-subtle"]; + const selectedBackgroundColor = theme["--color-user-bubble"]; const sidebarPane = props.pane === "sidebar"; const selected = props.selected === true; @@ -453,9 +432,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { ); const handleArchive = useCallback(() => onArchiveThread(thread), [onArchiveThread, thread]); - // Swipe: the v2 primary action is the lifecycle transition. Every settled - // row can un-settle — explicit settles clear the override, auto-settled - // rows get pinned active until real activity clears the pin. + // Swipe: the v2 primary action is the lifecycle transition. Un-settling a + // settled row keeps it active until new activity clears the user override. const canUnsettle = variant === "slim"; const [snoozeGateTick, bumpSnoozeGateTick] = useState(0); const snoozeGateExpiryMs = props.snoozeSupported @@ -698,7 +676,12 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { {props.projectTitle ?? props.project?.title ?? ""} {pinnedRow ? ( - + ) : null} @@ -904,7 +885,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { selected ? "text-user-bubble-foreground-muted" : snoozedRow - ? "text-blue-600 dark:text-blue-400" + ? "text-adaptive-blue-600-400" : "text-foreground-tertiary", )} style={{ fontFamily: MONO_FONT }} diff --git a/apps/mobile/src/features/threads/thread-search-match.tsx b/apps/mobile/src/features/threads/thread-search-match.tsx index da80ca0766ae..48aaf80249d5 100644 --- a/apps/mobile/src/features/threads/thread-search-match.tsx +++ b/apps/mobile/src/features/threads/thread-search-match.tsx @@ -65,8 +65,8 @@ export function ThreadSearchMatchExcerpt(props: { props.selected ? "text-user-bubble-foreground" : isUser - ? "text-blue-500 dark:text-blue-400" - : "text-emerald-600 dark:text-emerald-400", + ? "text-adaptive-blue-500-400" + : "text-adaptive-emerald-600-400", )} > {isUser ? "You:" : "Agent:"}{" "} diff --git a/apps/mobile/src/features/threads/thread-settings-sheet-state.test.ts b/apps/mobile/src/features/threads/thread-settings-sheet-state.test.ts index 2e8fee98572a..5c6e25f43785 100644 --- a/apps/mobile/src/features/threads/thread-settings-sheet-state.test.ts +++ b/apps/mobile/src/features/threads/thread-settings-sheet-state.test.ts @@ -12,7 +12,7 @@ function modelOption( return { key: `codex:${model}`, label: model, - subtitle: "Codex", + subtitle: "", providerKey: "codex", providerLabel: "Codex", providerDriver: "codex", @@ -48,6 +48,21 @@ describe("thread settings sheet state", () => { ).toBe(true); }); + it("matches the upstream provider's display name", () => { + const model = { + ...modelOption("opencode/claude-fable-5"), + label: "Claude Fable 5", + subtitle: "OpenCode Zen", + }; + + expect(modelMatchesCatalogQuery({ model, providerLabel: "OpenCode", query: " ZEN " })).toBe( + true, + ); + expect(modelMatchesCatalogQuery({ model, providerLabel: "OpenCode", query: "copilot" })).toBe( + false, + ); + }); + it("clears staging when the applied model is pressed", () => { expect( pendingModelAfterPress({ diff --git a/apps/mobile/src/features/threads/thread-work-log.tsx b/apps/mobile/src/features/threads/thread-work-log.tsx index a5adacb8d19b..d26f8976b9ca 100644 --- a/apps/mobile/src/features/threads/thread-work-log.tsx +++ b/apps/mobile/src/features/threads/thread-work-log.tsx @@ -1,31 +1,244 @@ import * as Haptics from "expo-haptics"; import { type AppSymbolName, SymbolView } from "../../components/AppSymbol"; -import { LayoutAnimation, Pressable, ScrollView, View } from "react-native"; +import { MaskedView } from "@expo/ui/community/masked-view"; +import { useIsFocused } from "@react-navigation/native"; +import { useEffect, useId, useLayoutEffect, useState, type ComponentProps } from "react"; +import { + AccessibilityInfo, + AppState, + type ColorValue, + Pressable, + ScrollView, + StyleSheet, + View, +} from "react-native"; +import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg"; import { AppText as Text } from "../../components/AppText"; -import { scaledTypographyLineHeight } from "../../lib/appearancePreferences"; import { cn } from "../../lib/cn"; import type { ThreadFeedActivity } from "../../lib/threadActivity"; -import { MOBILE_TYPOGRAPHY } from "../../lib/typography"; -import { useThemeColor } from "../../lib/useThemeColor"; -import Animated, { FadeIn } from "react-native-reanimated"; +import { + type ToolGroupSummaryKind, + workEntryViewedImagePath, +} from "@t3tools/client-runtime/work-log/presentation"; +import type { MarkdownImageRenderer } from "../../native/SelectableMarkdownText"; +import Animated, { + cancelAnimation, + Easing, + FadeIn, + FadeOut, + LinearTransition, + ReduceMotion, + useAnimatedStyle, + useSharedValue, + withDelay, + withRepeat, + withSequence, + withTiming, +} from "react-native-reanimated"; -const WORK_LOG_LAYOUT_ANIMATION = { - duration: 180, - create: { - type: LayoutAnimation.Types.easeInEaseOut, - property: LayoutAnimation.Properties.opacity, - }, - update: { type: LayoutAnimation.Types.easeInEaseOut }, - delete: { - type: LayoutAnimation.Types.easeInEaseOut, - property: LayoutAnimation.Properties.opacity, - }, -} as const; +const SHIMMER_WIDTH = 72; +const SHIMMER_SWEEP_MS = 1_350; +const SHIMMER_PAUSE_MS = 1_450; +const SHIMMER_ICON_AND_GAP_WIDTH = 30; +export const THREAD_DISCLOSURE_TRANSITION_MS = 180; +const WORK_LOG_LAYOUT_TRANSITION = LinearTransition.duration(THREAD_DISCLOSURE_TRANSITION_MS); +const WORK_LOG_DETAIL_ENTER_TRANSITION = FadeIn.duration(140); +const WORK_LOG_DETAIL_EXIT_TRANSITION = FadeOut.duration(120); -function triggerDisclosureFeedback() { - LayoutAnimation.configureNext(WORK_LOG_LAYOUT_ANIMATION); - void Haptics.selectionAsync(); +export function ThreadDisclosureChevron(props: { + readonly expanded: boolean; + readonly collapsedDirection: "right" | "down"; + readonly size: number; + readonly tintColor: ColorValue; +}) { + const expandedAngle = props.collapsedDirection === "right" ? 90 : 180; + const rotation = useSharedValue(props.expanded ? expandedAngle : 0); + + useLayoutEffect(() => { + rotation.value = withTiming(props.expanded ? expandedAngle : 0, { + duration: THREAD_DISCLOSURE_TRANSITION_MS, + reduceMotion: ReduceMotion.System, + }); + }, [expandedAngle, props.expanded, rotation]); + + const rotationStyle = useAnimatedStyle(() => ({ + transform: [{ rotate: `${rotation.value}deg` }], + })); + + return ( + + + + ); +} + +function ShimmerWorkContent(props: { + readonly highlighted: boolean; + readonly icon: AppSymbolName; + readonly iconSubtleColor: ColorValue; + readonly label: string; + readonly onTextLayout?: ComponentProps["onTextLayout"]; + readonly showIcon: boolean; +}) { + return ( + + + {props.showIcon ? ( + + ) : null} + + + {props.label} + + + ); +} + +export function ShimmeringWorkContent(props: { + readonly icon: AppSymbolName; + readonly iconSubtleColor: ColorValue; + readonly label: string; + readonly showIcon: boolean; +}) { + const [availableWidth, setAvailableWidth] = useState(0); + const [textWidth, setTextWidth] = useState(0); + const [appIsActive, setAppIsActive] = useState(AppState.currentState === "active"); + const [reducedMotion, setReducedMotion] = useState(true); + const screenIsFocused = useIsFocused(); + const progress = useSharedValue(0); + const gradientId = `work-shimmer-${useId().replaceAll(":", "")}`; + const contentWidth = Math.min(availableWidth, SHIMMER_ICON_AND_GAP_WIDTH + Math.ceil(textWidth)); + + useEffect(() => { + const subscription = AppState.addEventListener("change", (state) => { + setAppIsActive(state === "active"); + }); + return () => subscription.remove(); + }, []); + + useEffect(() => { + void AccessibilityInfo.isReduceMotionEnabled().then(setReducedMotion); + const subscription = AccessibilityInfo.addEventListener( + "reduceMotionChanged", + setReducedMotion, + ); + return () => subscription.remove(); + }, []); + + useEffect(() => { + cancelAnimation(progress); + progress.value = 0; + if (contentWidth <= 0 || reducedMotion || !appIsActive || !screenIsFocused) return; + + progress.value = withRepeat( + withSequence( + withTiming(1, { + duration: SHIMMER_SWEEP_MS, + easing: Easing.linear, + reduceMotion: ReduceMotion.Never, + }), + withDelay( + SHIMMER_PAUSE_MS, + withTiming(0, { duration: 0, reduceMotion: ReduceMotion.Never }), + ), + ), + -1, + false, + undefined, + ReduceMotion.Never, + ); + return () => cancelAnimation(progress); + }, [appIsActive, contentWidth, progress, reducedMotion, screenIsFocused]); + + const sweepStyle = useAnimatedStyle(() => ({ + transform: [{ translateX: -SHIMMER_WIDTH + progress.value * (contentWidth + SHIMMER_WIDTH) }], + })); + const counterSweepStyle = useAnimatedStyle(() => ({ + transform: [{ translateX: SHIMMER_WIDTH - progress.value * (contentWidth + SHIMMER_WIDTH) }], + })); + + return ( + setAvailableWidth(event.nativeEvent.layout.width)} + > + setTextWidth(event.nativeEvent.lines[0]?.width ?? 0)} + /> + {!reducedMotion && appIsActive && screenIsFocused && contentWidth > 0 ? ( + + + + + + + + + + + + + + + + } + > + + + + + + ) : null} + + ); } function stripShellWrapper(value: string): string { @@ -80,44 +293,23 @@ function isFreshRow(createdAt: string): boolean { return Number.isFinite(timestamp) && Date.now() - timestamp < FRESH_ROW_WINDOW_MS; } -// Tool-like activities with a neutral status carry no signal worth a row. -export function visibleWorkLogActivities( - activities: ReadonlyArray, -): ReadonlyArray { - return activities.filter((activity) => !(activity.toolLike && activity.status === "neutral")); -} - // Pre-measurement heights for the feed's getFixedItemSize. Collapsed work-log // rows are single-line (numberOfLines={1}) inside a min-height that stays -// taller than the text at every supported base font size (text-xs reaches -// 23px at the 22pt maximum, under the 32px min-h-8), so row height is -// deterministic. The "work log" label has no such clamp — its height follows -// the scaled text-2xs line height. Values mirror the classNames below — keep -// them in sync; a mismatch only costs a one-time correction on measure. +// taller than text-sm at every supported base font size, so row height is +// deterministic. Values mirror the classNames below. A mismatch only costs a +// one-time correction on measure. const WORK_ROW_HEIGHT = 32; // min-h-8 const WORK_ROW_GAP = 1; // gap-px -const WORK_LOG_HEADER_PADDING = 2; // pb-0.5 under the "work log" label const WORK_LOG_BOTTOM_MARGIN = 4; // mb-1 -export const WORK_GROUP_TOGGLE_HEIGHT = 36; // min-h-8 (32) + mb-1 (4) +export const WORK_GROUP_TOGGLE_HEIGHT = 32; // min-h-8 -export function collapsedWorkLogHeight( - activities: ReadonlyArray, - baseFontSize: number, -): number { - const rows = visibleWorkLogActivities(activities); +export function collapsedWorkLogHeight(activities: ReadonlyArray): number { + const rows = activities; if (rows.length === 0) { return 0; } - const onlyToolRows = rows.every((row) => row.toolLike); - const headerHeight = - scaledTypographyLineHeight(MOBILE_TYPOGRAPHY.caption, baseFontSize) + WORK_LOG_HEADER_PADDING; - return ( - WORK_LOG_BOTTOM_MARGIN + - (onlyToolRows ? 0 : headerHeight) + - rows.length * WORK_ROW_HEIGHT + - (rows.length - 1) * WORK_ROW_GAP - ); + return WORK_LOG_BOTTOM_MARGIN + rows.length * WORK_ROW_HEIGHT + (rows.length - 1) * WORK_ROW_GAP; } export function ThreadWorkLog(props: { @@ -127,9 +319,9 @@ export function ThreadWorkLog(props: { readonly iconSubtleColor: import("react-native").ColorValue; readonly onCopyRow: (rowId: string, value: string) => void; readonly onToggleRow: (rowId: string) => void; + readonly renderImage: MarkdownImageRenderer; }) { - const pressedBackground = useThemeColor("--color-subtle"); - const rows = visibleWorkLogActivities(props.activities).map((activity) => ({ + const rows = props.activities.map((activity) => ({ ...activity, detail: compactActivityDetail(activity.detail), })); @@ -138,32 +330,29 @@ export function ThreadWorkLog(props: { return null; } - const onlyToolRows = rows.every((row) => row.toolLike); - return ( - {!onlyToolRows ? ( - - work log - - ) : null} - {rows.map((row) => { const expanded = props.expandedRows[row.id] ?? false; const canExpand = row.canExpand; const fullDetail = expanded ? row.getFullDetail() : null; - const displayText = row.detail ? `${row.summary} ${row.detail}` : row.summary; + const viewedImagePath = workEntryViewedImagePath(row.workEntry); + const displayText = row.detail ?? row.summary; const iconIsDestructive = row.icon === "alert" || row.icon === "warning"; + const failed = row.status === "failure"; + const showIcon = !row.groupedToolDetail || iconIsDestructive || failed; return ( { if (canExpand) { - triggerDisclosureFeedback(); + void Haptics.selectionAsync(); props.onToggleRow(row.id); } }} onLongPress={() => props.onCopyRow(row.id, row.getCopyText())} - style={({ pressed }) => ({ - backgroundColor: pressed ? pressedBackground : "transparent", - })} - className="rounded-md px-0.5 py-0" + className="rounded-md px-0.5 py-0 active:bg-subtle" > - - - - - - - {row.summary} - - {row.detail ? ( - {row.detail} - ) : null} - + ) : ( + <> + + {showIcon ? ( + + ) : null} + + + {displayText} + + + )} {props.copiedRowId === row.id ? ( - + Copied ) : null} {canExpand ? ( - - ) : null} - - - {row.status ? ( - ) : null} @@ -249,7 +427,17 @@ export function ThreadWorkLog(props: { {fullDetail ? ( - + + {viewedImagePath ? ( + + {props.renderImage({ href: viewedImagePath, alt: null, title: null })} + + ) : null} - + ) : null} ); @@ -278,54 +466,85 @@ export function ThreadWorkGroupToggle(props: { readonly expanded: boolean; readonly hiddenCount: number; readonly iconSubtleColor: import("react-native").ColorValue; - readonly onlyToolActivities: boolean; + readonly summary: string; + readonly summaryKind: ToolGroupSummaryKind; + readonly hasFailure: boolean; + readonly shimmer: boolean; readonly onToggle: () => void; }) { - const pressedBackground = useThemeColor("--color-subtle"); - const noun = props.onlyToolActivities - ? props.hiddenCount === 1 - ? "tool call" - : "tool calls" - : props.hiddenCount === 1 - ? "log entry" - : "log entries"; - const collapsedLabel = `Show ${props.hiddenCount} previous ${noun}`; - const expandedLabel = props.onlyToolActivities - ? "Show fewer tool calls" - : "Show fewer log entries"; + const accessibilityLabel = props.hasFailure + ? `${props.summary}, tool call failed` + : props.summary; + const icon = toolGroupSummarySymbolName(props.summaryKind); return ( - + { void Haptics.selectionAsync(); props.onToggle(); }} - style={({ pressed }) => ({ - backgroundColor: pressed ? pressedBackground : "transparent", - })} - className="min-h-8 flex-row items-center gap-1.5 rounded-md px-0.5 py-0" + className="min-h-8 flex-row items-center gap-1.5 rounded-md px-0.5 py-0 active:bg-subtle" > - - - - - {props.expanded ? expandedLabel : `+${props.hiddenCount} previous ${noun}`} - + ) : ( + <> + + + + + {props.summary} + + + )} + ); } + +function toolGroupSummarySymbolName(kind: ToolGroupSummaryKind): AppSymbolName { + switch (kind) { + case "read": + return { ios: "eye", android: "visibility" }; + case "edit": + return { ios: "square.and.pencil", android: "edit" }; + case "command": + return { ios: "terminal", android: "terminal" }; + case "search": + return { ios: "globe", android: "public" }; + case "code-search": + return "magnifyingglass"; + case "other": + return { ios: "wrench", android: "build" }; + case "agent-tool": + return { ios: "sparkles", android: "auto_awesome" }; + case "tone-tool": + return { ios: "bolt", android: "bolt" }; + case "dynamic-tool": + case "update": + case "mixed": + return { ios: "hammer", android: "construction" }; + } +} diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts index 4439ea194778..48edf3906002 100644 --- a/apps/mobile/src/features/threads/threadListV2.test.ts +++ b/apps/mobile/src/features/threads/threadListV2.test.ts @@ -53,6 +53,12 @@ function makeThread( } const NOW = "2026-06-02T00:00:00.000Z"; +const linkedPullRequest = { + projectId: ProjectId.make("project-1"), + repository: "pingdotgg/t3code", + number: 42, + url: "https://github.com/pingdotgg/t3code/pull/42", +}; describe("resolveThreadListV2SnoozeMenuSelection", () => { it("accepts a displayed evening preset while its wake time is still future", () => { @@ -260,24 +266,39 @@ describe("sortThreadsForListV2", () => { ]); expect(sorted.map((thread) => thread.id)).toEqual(["newest", "middle", "oldest"]); }); + + it("surfaces an un-settled thread at the top via its re-entry stamp", () => { + const sorted = sortThreadsForListV2([ + { + id: "old-unsettled", + createdAt: "2026-06-01T08:00:00.000Z", + unsettledAt: "2026-06-01T13:00:00.000Z", + }, + { id: "newest", createdAt: "2026-06-01T12:00:00.000Z" }, + { id: "middle", createdAt: "2026-06-01T10:00:00.000Z" }, + ]); + expect(sorted.map((thread) => thread.id)).toEqual(["old-unsettled", "newest", "middle"]); + }); }); describe("buildThreadListV2Items", () => { - it("keeps a merged thread active when auto-settle on merge is off", () => { - const merged = makeThread({ id: ThreadId.make("merged"), title: "Merged" }); + it("places a persisted settled thread in the settled shelf", () => { + const thread = makeThread({ + id: ThreadId.make("linked-merged"), + title: "Linked merged pull request", + linkedPullRequest, + settledOverride: "settled", + settledAt: NOW, + }); const layout = buildThreadListV2Items({ - threads: [merged], + threads: [thread], environmentId: null, searchQuery: "", - changeRequestByKey: new Map([ - [`${environmentId}:${merged.id}`, { state: "merged" as const }], - ]), - autoSettleOnMerge: false, now: NOW, }); - expect(layout.items.map((item) => item.thread.id)).toEqual(["merged"]); - expect(layout.settledCount).toBe(0); + expect(layout.settledCount).toBe(1); + expect(layout.items[0]?.variant).toBe("slim"); }); it("hides snoozed threads and counts them — visibility parity with web", () => { @@ -331,73 +352,21 @@ describe("buildThreadListV2Items", () => { expect(layout.settledCount).toBe(1); }); - it("moves pinned threads to the settled shelf when their pull request merges", () => { - const merged = makeThread({ - id: ThreadId.make("pinned-merged"), - title: "Pinned merged pull request", - pinnedAt: "2026-06-01T12:00:00.000Z", - }); - const layout = buildThreadListV2Items({ - threads: [makeThread({ id: ThreadId.make("active"), title: "Active" }), merged], - environmentId: null, - searchQuery: "", - changeRequestByKey: new Map([[`${environmentId}:${merged.id}`, { state: "merged" }]]), - now: NOW, - }); - - expect(layout.items.map((item) => item.thread.id)).toEqual(["active", "pinned-merged"]); - expect(layout.items.map((item) => item.variant)).toEqual(["card", "slim"]); - expect(layout.items[1]?.thread.pinnedAt).toBe("2026-06-01T12:00:00.000Z"); - expect(layout.settledCount).toBe(1); - }); - - it("moves inactive pinned threads to the settled shelf", () => { - const inactive = makeThread({ - id: ThreadId.make("pinned-inactive"), - title: "Pinned inactive thread", - createdAt: "2026-05-20T00:00:00.000Z", - pinnedAt: "2026-05-21T00:00:00.000Z", - latestTurn: { - turnId: TurnId.make("turn-inactive"), - state: "completed", - requestedAt: "2026-05-21T00:00:00.000Z", - startedAt: "2026-05-21T00:00:01.000Z", - completedAt: "2026-05-21T00:00:02.000Z", - assistantMessageId: null, - }, - }); - const layout = buildThreadListV2Items({ - threads: [inactive], - environmentId: null, - searchQuery: "", - now: NOW, - }); - - expect(layout.items[0]).toMatchObject({ - thread: { id: "pinned-inactive" }, - variant: "slim", - pinned: false, - }); - expect(layout.settledCount).toBe(1); - }); - - it("keeps pinned merged threads pinned when auto-settle on merge is off", () => { - const merged = makeThread({ - id: ThreadId.make("pinned-merged"), - title: "Pinned merged pull request", + it("keeps active pinned threads in the pinned block", () => { + const pinned = makeThread({ + id: ThreadId.make("pinned"), + title: "Pinned thread", pinnedAt: "2026-06-01T12:00:00.000Z", }); const layout = buildThreadListV2Items({ - threads: [merged], + threads: [pinned], environmentId: null, searchQuery: "", - changeRequestByKey: new Map([[`${environmentId}:${merged.id}`, { state: "merged" }]]), - autoSettleOnMerge: false, now: NOW, }); expect(layout.items[0]).toMatchObject({ - thread: { id: "pinned-merged" }, + thread: { id: "pinned" }, variant: "card", pinned: true, }); @@ -452,9 +421,7 @@ describe("buildThreadListV2Items", () => { ], environmentId: null, searchQuery: "", - // Minute-floored partition clock vs precise snooze clock. - now: "2026-06-02T00:01:00.000Z", - snoozeNow: "2026-06-02T00:01:07.500Z", + now: "2026-06-02T00:01:07.500Z", }); expect(layout.items.map((item) => item.thread.id)).toEqual(["just-woke"]); diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index 11ac0e9dcb64..cf284b41605a 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -1,18 +1,17 @@ import { - effectiveSettled, effectiveSnoozed, hasQueuedTurnStart, QUEUED_TURN_START_GRACE_MS, resolveSnoozePresets, snoozeWakeLabel, } from "@t3tools/client-runtime/state/thread-settled"; -import type { - ChangeRequestSettleSource, - SnoozePreset, -} from "@t3tools/client-runtime/state/thread-settled"; +import type { SnoozePreset } from "@t3tools/client-runtime/state/thread-settled"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; -import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort"; +import { + activeThreadAnchorTimestampMs, + sortPinnedThreadsByOrderKey, +} from "@t3tools/client-runtime/state/thread-sort"; import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; @@ -162,19 +161,25 @@ function firstValidTimestampMs(...candidates: ReadonlyArray( - threads: readonly T[], -): T[] { +export function sortThreadsForListV2< + T extends { + readonly id: string; + readonly createdAt: string; + readonly unsettledAt?: string | null | undefined; + }, +>(threads: readonly T[]): T[] { // .sort() on a copy, not .toSorted(): Hermes doesn't ship the ES2023 // change-by-copy array methods. return [...threads].sort( (left, right) => - parseTimestampMs(right.createdAt) - parseTimestampMs(left.createdAt) || + activeThreadAnchorTimestampMs(right) - activeThreadAnchorTimestampMs(left) || left.id.localeCompare(right.id), ); } @@ -309,8 +314,7 @@ export function buildThreadListV2ListItems(input: { /** * Partitions visible threads into the active card block (creation order) and - * the settled recency tail, matching the web v2 list. Mobile stores these - * auto-settle preferences per device. + * the settled recency tail, matching the web v2 list. */ export function buildThreadListV2Items(input: { readonly threads: ReadonlyArray; @@ -321,8 +325,6 @@ export function buildThreadListV2Items(input: { }> | null; readonly searchQuery: string; readonly matchedThreadKeys?: ReadonlySet; - /** Per-row PR reported up by visible rows ("env:threadId" keys). */ - readonly changeRequestByKey?: ReadonlyMap; /** Environments whose server supports thread.settle/unsettle. Threads on other environments never classify as settled — the user could neither un-settle nor pin them. Absent = no gating (tests). */ @@ -330,17 +332,10 @@ export function buildThreadListV2Items(input: { /** Environments whose server supports thread.snooze/unsnooze. Same contract as settlementEnvironmentIds. */ readonly snoozeEnvironmentIds?: ReadonlySet; - readonly autoSettleAfterDays?: number; - readonly autoSettleOnMerge?: boolean; /** Max settled rows to render; the rest are counted, not built. */ readonly settledLimit?: number; - /** Injectable for tests; defaults to now. */ - readonly now?: string; - /** Second-precise clock for snooze classification. Callers pass a - minute-quantized `now` for memoization; snooze wake times are - second-precise, so classifying with the floored minute would hold a - woken thread hidden for up to a minute. Defaults to `now`. */ - readonly snoozeNow?: string; + /** Second-precise clock used for time-based classification. */ + readonly now: string; /** Expands the snoozed shelf into rows. Collapsed is the default. */ readonly snoozedShelfExpanded?: boolean; /** Expands the settled shelf into rows. Expanded is the default. */ @@ -349,10 +344,7 @@ export function buildThreadListV2Items(input: { a split-view detail can never lose its navigation row. */ readonly selectedThreadKey?: string | null; }): ThreadListV2Layout { - const now = input.now ?? new Date().toISOString(); - const snoozeNow = input.snoozeNow ?? now; - const autoSettleAfterDays = input.autoSettleAfterDays ?? 3; - const autoSettleOnMerge = input.autoSettleOnMerge ?? true; + const now = input.now; const query = input.searchQuery.trim().toLocaleLowerCase(); const projectKeys = input.projectRefs ? new Set(input.projectRefs.map((ref) => `${ref.environmentId}:${ref.projectId}`)) @@ -364,8 +356,7 @@ export function buildThreadListV2Items(input: { const snoozed: EnvironmentThreadShell[] = []; let nextSnoozeWakeAt: string | null = null; for (const thread of input.threads) { - // Callers pass live (unarchived) shells; settled threads are among them - // and partition into the tail via effectiveSettled. + // Callers pass live shells. The server stamps settledOverride for the tail. if (input.environmentId !== null && thread.environmentId !== input.environmentId) continue; if (projectKeys !== null && !projectKeys.has(`${thread.environmentId}:${thread.projectId}`)) { continue; @@ -384,10 +375,8 @@ export function buildThreadListV2Items(input: { } const supportsSettlement = input.settlementEnvironmentIds?.has(thread.environmentId) ?? true; const supportsSnooze = input.snoozeEnvironmentIds?.has(thread.environmentId) ?? true; - const changeRequest = - input.changeRequestByKey?.get(`${thread.environmentId}:${thread.id}`) ?? null; // Snooze outranks settlement and pinning until the thread wakes. - if (supportsSnooze && effectiveSnoozed(thread, { now: snoozeNow })) { + if (supportsSnooze && effectiveSnoozed(thread, { now })) { snoozed.push(thread); if ( thread.snoozedUntil != null && @@ -398,15 +387,7 @@ export function buildThreadListV2Items(input: { } continue; } - if ( - supportsSettlement && - effectiveSettled(thread, { - now, - autoSettleAfterDays, - autoSettleOnMerge, - changeRequest, - }) - ) { + if (supportsSettlement && thread.settledOverride === "settled") { settled.push(thread); } else if (thread.pinnedAt != null) { pinned.push(thread); diff --git a/apps/mobile/src/features/threads/threadPresentation.ts b/apps/mobile/src/features/threads/threadPresentation.ts index 9de3d4d3089f..59cf108a01dd 100644 --- a/apps/mobile/src/features/threads/threadPresentation.ts +++ b/apps/mobile/src/features/threads/threadPresentation.ts @@ -2,11 +2,6 @@ import type { StatusTone } from "../../components/StatusPill"; import type { OrchestrationLatestTurn, OrchestrationSession } from "@t3tools/contracts"; import { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; -export function threadSortValue(thread: EnvironmentThreadShell): number { - const candidate = Date.parse(thread.updatedAt ?? thread.createdAt); - return Number.isNaN(candidate) ? 0 : candidate; -} - export type ThreadStatusKind = | "pending-approval" | "awaiting-input" @@ -25,12 +20,6 @@ export interface ThreadStatusPresentation extends StatusTone { readonly pulse: boolean; } -/** Neutral icon colors for threads with no actionable status. */ -export const THREAD_STATUS_NEUTRAL_ICON = { - iconColor: "#8e8e93", - iconBackground: "rgba(142,142,147,0.22)", -} as const; - function isLatestTurnSettled( latestTurn: OrchestrationLatestTurn | null, session: OrchestrationSession | null, @@ -53,8 +42,8 @@ export function resolveThreadStatus( return { kind: "pending-approval", label: "Needs Approval", - pillClassName: "bg-amber-500/12 dark:bg-amber-500/16", - textClassName: "text-amber-700 dark:text-amber-300", + pillClassName: "bg-adaptive-amber-500-a12-a16", + textClassName: "text-adaptive-amber-700-300", iconColor: "#ff9f0a", iconBackground: "rgba(255,159,10,0.22)", pulse: false, @@ -65,8 +54,8 @@ export function resolveThreadStatus( return { kind: "awaiting-input", label: "Awaiting Input", - pillClassName: "bg-indigo-500/12 dark:bg-indigo-500/16", - textClassName: "text-indigo-700 dark:text-indigo-300", + pillClassName: "bg-adaptive-indigo-500-a12-a16", + textClassName: "text-adaptive-indigo-700-300", iconColor: "#5e5ce6", iconBackground: "rgba(94,92,230,0.22)", pulse: false, @@ -77,8 +66,8 @@ export function resolveThreadStatus( return { kind: "working", label: "Working", - pillClassName: "bg-sky-500/12 dark:bg-sky-500/16", - textClassName: "text-sky-700 dark:text-sky-300", + pillClassName: "bg-adaptive-sky-500-a12-a16", + textClassName: "text-adaptive-sky-700-300", iconColor: "#0a84ff", iconBackground: "rgba(10,132,255,0.22)", pulse: true, @@ -89,8 +78,8 @@ export function resolveThreadStatus( return { kind: "connecting", label: "Connecting", - pillClassName: "bg-sky-500/12 dark:bg-sky-500/16", - textClassName: "text-sky-700 dark:text-sky-300", + pillClassName: "bg-adaptive-sky-500-a12-a16", + textClassName: "text-adaptive-sky-700-300", iconColor: "#0a84ff", iconBackground: "rgba(10,132,255,0.22)", pulse: true, @@ -101,8 +90,8 @@ export function resolveThreadStatus( return { kind: "error", label: "Error", - pillClassName: "bg-rose-500/12 dark:bg-rose-500/16", - textClassName: "text-rose-700 dark:text-rose-300", + pillClassName: "bg-adaptive-rose-500-a12-a16", + textClassName: "text-adaptive-rose-700-300", iconColor: "#ff453a", iconBackground: "rgba(255,69,58,0.22)", pulse: false, @@ -117,8 +106,8 @@ export function resolveThreadStatus( return { kind: "plan-ready", label: "Plan Ready", - pillClassName: "bg-violet-500/12 dark:bg-violet-500/16", - textClassName: "text-violet-700 dark:text-violet-300", + pillClassName: "bg-adaptive-violet-500-a12-a16", + textClassName: "text-adaptive-violet-700-300", iconColor: "#bf5af2", iconBackground: "rgba(191,90,242,0.22)", pulse: false, diff --git a/apps/mobile/src/features/threads/use-composer-command-menu.test.ts b/apps/mobile/src/features/threads/use-composer-command-menu.test.ts new file mode 100644 index 000000000000..9db123450f9a --- /dev/null +++ b/apps/mobile/src/features/threads/use-composer-command-menu.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it, vi } from "vite-plus/test"; + +vi.mock("../../state/queries", () => ({ + useComposerPathSearch: () => ({ entries: [], isPending: false }), +})); + +import { composerSelectionAtEnd } from "./use-composer-command-menu"; + +describe("composerSelectionAtEnd", () => { + it("resets a changed draft owner to the new draft end", () => { + expect(composerSelectionAtEnd("queued task 🧪")).toEqual({ start: 14, end: 14 }); + }); +}); diff --git a/apps/mobile/src/features/threads/use-composer-command-menu.ts b/apps/mobile/src/features/threads/use-composer-command-menu.ts new file mode 100644 index 000000000000..966ceedeec85 --- /dev/null +++ b/apps/mobile/src/features/threads/use-composer-command-menu.ts @@ -0,0 +1,305 @@ +import type { EnvironmentId, ProviderInteractionMode, ServerProvider } from "@t3tools/contracts"; +import { + detectComposerTrigger, + replaceTextRange, + serializeComposerFileLink, +} from "@t3tools/shared/composerTrigger"; +import { + insertRankedSearchResult, + normalizeSearchQuery, + scoreQueryMatch, +} from "@t3tools/shared/searchRanking"; +import { + dedupeProviderSkillsByName, + getProviderSkillsForSlashMenu, + isProviderSkillUserInvocable, +} from "@t3tools/client-runtime/providerSkills"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import type { ComposerEditorSelection } from "../../components/ComposerEditor"; +import { useComposerPathSearch } from "../../state/queries"; +import type { ComposerCommandItem } from "./ComposerCommandPopover"; +import { matchesSlashSkillQuery } from "./composerSlashSkillSearch"; + +export function composerSelectionAtEnd(draftMessage: string): ComposerEditorSelection { + return { start: draftMessage.length, end: draftMessage.length }; +} + +/** Shared autocomplete for thread composers and unsent new-task drafts. */ +export function useComposerCommandMenu({ + draftMessage, + ownerKey, + environmentId, + projectCwd, + selectedProviderStatus, + hasThread, + enabled = true, + onChangeDraftMessage, + onUpdateInteractionMode, +}: { + readonly draftMessage: string; + readonly ownerKey: string | null; + readonly environmentId: EnvironmentId | null; + readonly projectCwd: string | null; + readonly selectedProviderStatus: ServerProvider | null; + readonly hasThread: boolean; + readonly enabled?: boolean; + readonly onChangeDraftMessage: (value: string) => void; + readonly onUpdateInteractionMode?: (mode: ProviderInteractionMode) => void; +}) { + const [selection, setSelection] = useState(() => composerSelectionAtEnd(draftMessage)); + const previousOwnerKeyRef = useRef(ownerKey); + const onSelectionChange = useCallback((nextSelection: ComposerEditorSelection) => { + setSelection(nextSelection); + }, []); + useEffect(() => { + const end = draftMessage.length; + setSelection((current) => { + const start = Math.min(current.start, end); + const selectionEnd = Math.min(current.end, end); + if (start === current.start && selectionEnd === current.end) { + return current; + } + return { start, end: selectionEnd }; + }); + }, [draftMessage.length]); + useEffect(() => { + if (previousOwnerKeyRef.current === ownerKey) return; + previousOwnerKeyRef.current = ownerKey; + setSelection(composerSelectionAtEnd(draftMessage)); + }, [draftMessage, ownerKey]); + + const trigger = useMemo(() => { + if (!enabled || selection.start !== selection.end) { + return null; + } + return detectComposerTrigger(draftMessage, selection.end); + }, [draftMessage, enabled, selection]); + const pathSearch = useComposerPathSearch({ + environmentId, + cwd: trigger?.kind === "path" ? projectCwd : null, + query: trigger?.kind === "path" ? trigger.query : null, + }); + + const items = useMemo(() => { + if (!trigger) return []; + + if (trigger.kind === "slash-command") { + const q = trigger.query.toLowerCase(); + const allBuiltIn = [ + { + id: "cmd:model", + type: "slash-command" as const, + command: "model", + label: "/model", + description: "Switch model", + }, + { + id: "cmd:plan", + type: "slash-command" as const, + command: "plan", + label: "/plan", + description: "Switch to plan mode", + }, + { + id: "cmd:default", + type: "slash-command" as const, + command: "default", + label: "/default", + description: "Switch to default mode", + }, + ]; + const builtIn = allBuiltIn.filter( + (item) => + item.command.includes(q) && + (item.command === "model" || onUpdateInteractionMode !== undefined), + ); + + // A provider expands a slash command only when it opens the whole + // message; elsewhere it arrives as literal text. Built-ins apply + // locally and skills insert a `$` mention the server dispatches from + // any position, so only provider commands are position-gated. + const providerCommands: ComposerCommandItem[] = []; + const expandableCommands = + trigger.rangeStart === 0 ? (selectedProviderStatus?.slashCommands ?? []) : []; + for (const command of expandableCommands) { + if (!command.name.toLowerCase().includes(q)) continue; + // Codex feedback uploads an existing thread's session and logs. + if ( + !hasThread && + selectedProviderStatus?.driver === "codex" && + command.name === "feedback" + ) { + continue; + } + providerCommands.push({ + id: `pcmd:${command.name}`, + type: "provider-slash-command", + command, + label: `/${command.name}`, + description: command.description ?? "", + }); + } + + const skillItems = getProviderSkillsForSlashMenu(selectedProviderStatus?.skills ?? [], true) + .filter((skill) => matchesSlashSkillQuery(skill, q)) + .map((skill) => ({ + id: `skill:${skill.name}`, + type: "skill" as const, + skill, + label: `skill:${skill.name}`, + description: skill.shortDescription ?? skill.description ?? "", + })); + + return [...builtIn, ...providerCommands, ...skillItems]; + } + + if (trigger.kind === "skill") { + const enabledSkills = dedupeProviderSkillsByName( + (selectedProviderStatus?.skills ?? []).filter(isProviderSkillUserInvocable), + ); + const normalizedQuery = normalizeSearchQuery(trigger.query, { + trimLeadingPattern: /^\$+/, + }); + + if (!normalizedQuery) { + return enabledSkills.slice(0, 20).map((skill) => ({ + id: `skill:${skill.name}`, + type: "skill" as const, + skill, + label: skill.displayName ?? skill.name, + description: skill.shortDescription ?? skill.description ?? "", + })); + } + + const ranked: Array<{ + item: (typeof enabledSkills)[number]; + score: number; + tieBreaker: string; + }> = []; + for (const skill of enabledSkills) { + const displayLabel = (skill.displayName ?? skill.name).toLowerCase(); + const scores = [ + scoreQueryMatch({ + value: skill.name.toLowerCase(), + query: normalizedQuery, + exactBase: 0, + prefixBase: 2, + boundaryBase: 4, + includesBase: 6, + fuzzyBase: 100, + boundaryMarkers: ["-", "_", "/"], + }), + scoreQueryMatch({ + value: displayLabel, + query: normalizedQuery, + exactBase: 1, + prefixBase: 3, + boundaryBase: 5, + includesBase: 7, + fuzzyBase: 110, + }), + scoreQueryMatch({ + value: skill.shortDescription?.toLowerCase() ?? "", + query: normalizedQuery, + exactBase: 20, + prefixBase: 22, + boundaryBase: 24, + includesBase: 26, + }), + scoreQueryMatch({ + value: skill.description?.toLowerCase() ?? "", + query: normalizedQuery, + exactBase: 30, + prefixBase: 32, + boundaryBase: 34, + includesBase: 36, + }), + ].filter((score): score is number => score !== null); + + if (scores.length > 0) { + insertRankedSearchResult( + ranked, + { + item: skill, + score: Math.min(...scores), + tieBreaker: `${displayLabel}\u0000${skill.name}`, + }, + 20, + ); + } + } + + return ranked.map(({ item: skill }) => ({ + id: `skill:${skill.name}`, + type: "skill" as const, + skill, + label: skill.displayName ?? skill.name, + description: skill.shortDescription ?? skill.description ?? "", + })); + } + + if (trigger.kind === "path") { + return pathSearch.entries.map((entry) => { + const parts = entry.path.split("/"); + return { + id: `path:${entry.path}`, + type: "path" as const, + path: entry.path, + kind: entry.kind, + label: parts[parts.length - 1] ?? entry.path, + description: parts.length > 1 ? parts.slice(0, -1).join("/") : "", + }; + }); + } + + return []; + }, [hasThread, onUpdateInteractionMode, pathSearch.entries, selectedProviderStatus, trigger]); + + const onSelect = useCallback( + (item: ComposerCommandItem) => { + if (!trigger) return; + + if ( + item.type === "slash-command" && + (item.command === "plan" || item.command === "default") + ) { + const result = replaceTextRange(draftMessage, trigger.rangeStart, trigger.rangeEnd, ""); + setSelection({ start: result.cursor, end: result.cursor }); + onChangeDraftMessage(result.text); + onUpdateInteractionMode?.(item.command); + return; + } + + let replacement = ""; + if (item.type === "path") { + replacement = `${serializeComposerFileLink(item.path)} `; + } else if (item.type === "skill") { + replacement = `$${item.skill.name} `; + } else if (item.type === "slash-command") { + replacement = `/${item.command} `; + } else if (item.type === "provider-slash-command") { + replacement = `/${item.command.name} `; + } + + const result = replaceTextRange( + draftMessage, + trigger.rangeStart, + trigger.rangeEnd, + replacement, + ); + setSelection({ start: result.cursor, end: result.cursor }); + onChangeDraftMessage(result.text); + }, + [draftMessage, onChangeDraftMessage, onUpdateInteractionMode, trigger], + ); + + return { + selection, + onSelectionChange, + trigger, + items, + isLoading: pathSearch.isPending, + onSelect, + }; +} diff --git a/apps/mobile/src/features/threads/use-legacy-plan-mode-enabled.ts b/apps/mobile/src/features/threads/use-legacy-plan-mode-enabled.ts index 25ec4ff0e7d8..61c4fb65cdc9 100644 --- a/apps/mobile/src/features/threads/use-legacy-plan-mode-enabled.ts +++ b/apps/mobile/src/features/threads/use-legacy-plan-mode-enabled.ts @@ -9,10 +9,6 @@ import { resolveLegacyPlanModeEnabled } from "./legacy-plan-mode"; * Keep the legacy composer mode hidden until the preference has loaded and is * explicitly enabled. */ -export function useLegacyPlanModeEnabled(): boolean { - return useLegacyPlanModeState().enabled; -} - export function useLegacyPlanModeState(): { readonly enabled: boolean; readonly loaded: boolean } { const preferences = useAtomValue(mobilePreferencesAtom); const loaded = AsyncResult.isSuccess(preferences); diff --git a/apps/mobile/src/features/threads/use-project-actions.ts b/apps/mobile/src/features/threads/use-project-actions.ts index 9d03dde59a93..e9722e7db49c 100644 --- a/apps/mobile/src/features/threads/use-project-actions.ts +++ b/apps/mobile/src/features/threads/use-project-actions.ts @@ -14,13 +14,17 @@ import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/unstable/reactivity"; import { threadEnvironment } from "../../state/threads"; -import type { DraftComposerImageAttachment } from "../../lib/composerImages"; +import type { DraftComposerAttachment } from "../../lib/composerImages"; +import { prepareTurnAttachments, validateDraftFileAttachments } from "../../lib/attachmentUpload"; import { makeTurnCommandMetadata, type TurnCommandMetadata } from "../../lib/commandMetadata"; import { buildProjectThreadStartTurnInput } from "../../lib/projectThreadStartTurn"; import { randomHex } from "../../lib/uuid"; import { useAtomCommand } from "../../state/use-atom-command"; +import { scheduleUnusedComposerAttachmentCleanup } from "../../state/use-composer-drafts"; import { setPendingConnectionError } from "../../state/use-remote-environment-registry"; import { validateProjectThreadCreation } from "./projectThreadCreationValidation"; +import { appAtomRegistry } from "../../state/atom-registry"; +import { serverEnvironment } from "../../state/server"; export function useCreateProjectThread() { const startTurn = useAtomCommand(threadEnvironment.startTurn, { reportFailure: false }); @@ -36,7 +40,10 @@ export function useCreateProjectThread() { readonly runtimeMode: RuntimeMode; readonly interactionMode: ProviderInteractionMode; readonly initialMessageText: string; - readonly initialAttachments: ReadonlyArray; + readonly initialAttachments: ReadonlyArray; + readonly onAttachmentsUploaded: ( + attachments: ReadonlyArray, + ) => Promise; /** Reuse identifiers from a queued pending task instead of minting new ones. */ readonly turnMetadata?: TurnCommandMetadata; }) => { @@ -56,6 +63,53 @@ export function useCreateProjectThread() { return AsyncResult.failure(Cause.fail(validationError)); } + const validateLiveFileAttachments = ( + attachments: ReadonlyArray, + ): string | null => + validateDraftFileAttachments({ + attachments, + serverConfig: appAtomRegistry.get( + serverEnvironment.configValueAtom(input.project.environmentId), + ), + }); + const initialAttachmentError = validateLiveFileAttachments(input.initialAttachments); + if (initialAttachmentError !== null) { + setPendingConnectionError(initialAttachmentError); + return AsyncResult.failure(Cause.fail(new Error(initialAttachmentError))); + } + + let prepared: Awaited>; + try { + // If persisting the references into the draft throws, the owner call + // deletes the pending uploads it minted before rethrowing. + prepared = await prepareTurnAttachments({ + environmentId: input.project.environmentId, + attachments: input.initialAttachments, + supportsImageUploads: + appAtomRegistry.get(serverEnvironment.configValueAtom(input.project.environmentId)) + ?.environment.capabilities.attachmentUploads === true, + persistUploadedReferences: async (draftAttachments) => { + await input.onAttachmentsUploaded(draftAttachments); + return "persisted"; + }, + }); + } catch (error) { + const message = error instanceof Error ? error.message : "An attachment could not upload."; + setPendingConnectionError(message); + return AsyncResult.failure(Cause.fail(new Error(message))); + } + if (prepared.status !== "ready") { + const message = "The attachments are no longer available."; + setPendingConnectionError(message); + return AsyncResult.failure(Cause.fail(new Error(message))); + } + + const preparedAttachmentError = validateLiveFileAttachments(prepared.draftAttachments); + if (preparedAttachmentError !== null) { + setPendingConnectionError(preparedAttachmentError); + return AsyncResult.failure(Cause.fail(new Error(preparedAttachmentError))); + } + const result = await startTurn({ environmentId: input.project.environmentId, input: buildProjectThreadStartTurnInput({ @@ -67,6 +121,7 @@ export function useCreateProjectThread() { createdAt: metadata.createdAt, text: initialMessageText, attachments: input.initialAttachments, + uploadedAttachments: prepared.attachments, modelSelection: input.modelSelection, runtimeMode: input.runtimeMode, interactionMode: input.interactionMode, @@ -84,7 +139,13 @@ export function useCreateProjectThread() { ); return AsyncResult.failure(result.cause); } + // The started turn holds its own copy of the bytes; a failed delete is + // surfaced without failing the started task. + await prepared.releaseUploads().catch((error) => { + console.warn("[project-thread] could not delete consumed pending uploads", error); + }); setPendingConnectionError(null); + scheduleUnusedComposerAttachmentCleanup(input.initialAttachments); return mapAtomCommandResult(result, () => scopeThreadRef(input.project.environmentId, threadId), diff --git a/apps/mobile/src/features/usage/usageProviders.ts b/apps/mobile/src/features/usage/usageProviders.ts index 9a9ec5f2282d..2576ac21fb07 100644 --- a/apps/mobile/src/features/usage/usageProviders.ts +++ b/apps/mobile/src/features/usage/usageProviders.ts @@ -5,21 +5,23 @@ import { useAppearancePreferences } from "../settings/appearance/AppearancePrefe * Series and table order. The chart stacks providers from the bottom in this * order, so it also fixes which band sits on top of the bars. */ -export const PROVIDER_ORDER: readonly UsageProviderKind[] = ["codex", "claude"]; +export const PROVIDER_ORDER: readonly UsageProviderKind[] = ["codex", "claude", "grok"]; export const PROVIDER_LABEL: Record = { claude: "Claude Code", codex: "Codex", + grok: "Grok Build", }; /** - * Claude's brand orange holds in both themes; Codex is neutral and must flip - * with the theme or its bars vanish against the matching background. + * Claude's brand orange holds in both themes; Codex and Grok are neutrals and + * must flip with the theme or their bars vanish against the matching background. */ export function useProviderColors(): Record { const { themeAppearance: scheme } = useAppearancePreferences(); return { claude: "#d97757", codex: scheme === "dark" ? "#e6e6e6" : "#3c3c43", + grok: scheme === "dark" ? "#a1a1aa" : "#52525b", }; } diff --git a/apps/mobile/src/features/voice-input/ComposerDictationControl.tsx b/apps/mobile/src/features/voice-input/ComposerDictationControl.tsx new file mode 100644 index 000000000000..93838440ad69 --- /dev/null +++ b/apps/mobile/src/features/voice-input/ComposerDictationControl.tsx @@ -0,0 +1,419 @@ +import type { VoiceInputPhase, VoiceInputState } from "@t3tools/client-runtime/voice-input"; +import { memo, useCallback, useLayoutEffect, useState, type ReactNode } from "react"; +import { + ActivityIndicator, + Linking, + Platform, + Pressable, + View, + type LayoutChangeEvent, +} from "react-native"; +import Animated, { + Easing, + LinearTransition, + ReduceMotion, + useAnimatedStyle, + useSharedValue, + withTiming, + type EntryExitAnimationFunction, + type SharedValue, +} from "react-native-reanimated"; + +import { AppText as Text } from "../../components/AppText"; +import { SymbolView, type AppSymbolName } from "../../components/AppSymbol"; +import { cn } from "../../lib/cn"; +import type { VoiceComposerPresentation } from "./voiceInputPresentation"; +import { VOICE_WAVEFORM_SAMPLE_COUNT } from "./voiceInputMetering"; + +const DICTATION_TIMING = { + duration: 220, + easing: Easing.out(Easing.cubic), + reduceMotion: ReduceMotion.System, +} as const; +const DICTATION_LAYOUT = + Platform.OS === "android" + ? undefined + : LinearTransition.duration(DICTATION_TIMING.duration).reduceMotion(ReduceMotion.System); +const TOOLBAR_FLIP_TIMING = { + duration: 260, + easing: Easing.inOut(Easing.cubic), + reduceMotion: ReduceMotion.System, +} as const; +const TOOLBAR_HALF_HEIGHT = 22; +const TOOLBAR_PERSPECTIVE = 600; + +/** Moves each face around the same horizontal axis, keeping their edges together. */ +function toolbarFlip(fromDegrees: number, toDegrees: number): EntryExitAnimationFunction { + return () => { + "worklet"; + const fromRadians = (fromDegrees * Math.PI) / 180; + const toRadians = (toDegrees * Math.PI) / 180; + const fromSine = Math.sin(fromRadians); + const toSine = Math.sin(toRadians); + return { + initialValues: { + opacity: fromDegrees === 0 ? 1 : 0, + transform: [ + { perspective: TOOLBAR_PERSPECTIVE }, + { translateY: -TOOLBAR_HALF_HEIGHT * fromSine }, + { rotateX: `${fromDegrees}deg` }, + ], + }, + animations: { + opacity: withTiming(toDegrees === 0 ? 1 : 0, TOOLBAR_FLIP_TIMING), + transform: [ + { perspective: TOOLBAR_PERSPECTIVE }, + { + translateY: withTiming(-TOOLBAR_HALF_HEIGHT * toSine, { + ...TOOLBAR_FLIP_TIMING, + easing: (time) => { + const angle = + fromRadians + (toRadians - fromRadians) * TOOLBAR_FLIP_TIMING.easing(time); + return (Math.sin(angle) - fromSine) / (toSine - fromSine); + }, + }), + }, + { rotateX: withTiming(`${toDegrees}deg`, TOOLBAR_FLIP_TIMING) }, + ], + }, + }; + }; +} + +const DRAFT_TOOLBAR_ENTERING = toolbarFlip(90, 0); +const DRAFT_TOOLBAR_EXITING = toolbarFlip(0, 90); +const DICTATION_TOOLBAR_ENTERING = toolbarFlip(-90, 0); +const DICTATION_TOOLBAR_EXITING = toolbarFlip(0, -90); +const WAVEFORM_BAR_HEIGHT = 32; +const WAVEFORM_MIN_BAR_HEIGHT = 2; +const WAVEFORM_BAR_SPACING = 5; +const WAVEFORM_TIMING = { + duration: 100, + easing: Easing.out(Easing.quad), + reduceMotion: ReduceMotion.System, +} as const; + +/** Rotates the compact draft away without unmounting or resizing its native editor. */ +export function ComposerDictationDraftContent(props: { + readonly children: ReactNode; + readonly className?: string; + readonly compact: boolean; + readonly hidden: boolean; +}) { + const rotation = useSharedValue(props.hidden ? 1 : 0); + useLayoutEffect(() => { + rotation.value = withTiming(props.hidden ? 1 : 0, TOOLBAR_FLIP_TIMING); + }, [props.hidden, rotation]); + const compact = props.compact; + const animatedStyle = useAnimatedStyle(() => ({ + opacity: compact ? 1 - rotation.value : 1, + transform: compact + ? [ + { perspective: TOOLBAR_PERSPECTIVE }, + { translateY: -TOOLBAR_HALF_HEIGHT * Math.sin((rotation.value * Math.PI) / 2) }, + { rotateX: `${rotation.value * 90}deg` }, + ] + : [], + })); + + return ( + + {props.children} + + ); +} + +/** Flips the entire row while keeping the outgoing controls intact until it leaves. */ +export function ComposerDictationToolbar(props: { + readonly children: ReactNode; + readonly showsDictation: boolean; + readonly visible?: boolean; +}) { + return ( + + {props.visible !== false ? ( + + {props.children} + + ) : null} + + ); +} + +const WaveformBar = memo(function WaveformBar(props: { + readonly audioLevels: SharedValue; + readonly sampleIndex: number; +}) { + const { audioLevels, sampleIndex } = props; + const animatedStyle = useAnimatedStyle(() => { + const level = audioLevels.value[sampleIndex] ?? 0; + return { + opacity: withTiming(0.22 + level * 0.78, WAVEFORM_TIMING), + transform: [ + { + scaleY: withTiming( + (WAVEFORM_MIN_BAR_HEIGHT + level * (WAVEFORM_BAR_HEIGHT - WAVEFORM_MIN_BAR_HEIGHT)) / + WAVEFORM_BAR_HEIGHT, + WAVEFORM_TIMING, + ), + }, + ], + }; + }); + + return ( + + ); +}); + +const VoiceWaveform = memo(function VoiceWaveform(props: { + readonly audioLevels: SharedValue; +}) { + const [barCount, setBarCount] = useState(0); + const handleLayout = useCallback((event: LayoutChangeEvent) => { + setBarCount( + Math.max( + 1, + Math.min( + VOICE_WAVEFORM_SAMPLE_COUNT, + Math.floor(event.nativeEvent.layout.width / WAVEFORM_BAR_SPACING), + ), + ), + ); + }, []); + + return ( + + {Array.from({ length: barCount }, (_, index) => ( + + ))} + + ); +}); + +function VoiceActionButton(props: { + readonly accessibilityLabel: string; + readonly disabled?: boolean; + readonly icon: AppSymbolName; + readonly loading?: boolean; + readonly onPress: () => void; + readonly variant?: "plain" | "primary"; +}) { + const variant = props.variant ?? "plain"; + const loadingVisibility = useSharedValue(props.loading ? 1 : 0); + useLayoutEffect(() => { + loadingVisibility.value = withTiming(props.loading ? 1 : 0, DICTATION_TIMING); + }, [loadingVisibility, props.loading]); + const primaryStyle = useAnimatedStyle(() => ({ opacity: 1 - loadingVisibility.value })); + + return ( + + + {variant === "primary" ? ( + + ) : null} + + {props.loading ? ( + + ) : ( + + )} + + + + ); +} + +export function ComposerDictationStatus(props: { + readonly audioLevels: SharedValue; + readonly elapsedSeconds: number; + readonly phase: VoiceInputPhase; + readonly presentation: VoiceComposerPresentation; + readonly onDismissError: () => void; +}) { + const recordingVisibility = useSharedValue(props.phase === "recording" ? 1 : 0); + useLayoutEffect(() => { + recordingVisibility.value = withTiming(props.phase === "recording" ? 1 : 0, DICTATION_TIMING); + }, [props.phase, recordingVisibility]); + const waveformStyle = useAnimatedStyle(() => ({ + opacity: recordingVisibility.value, + })); + const labelStyle = useAnimatedStyle(() => ({ + opacity: 1 - recordingVisibility.value, + })); + + if (!props.presentation.statusLabel) return null; + const isError = props.presentation.statusKind === "error"; + const elapsedLabel = `${Math.floor(props.elapsedSeconds / 60)}:${String(props.elapsedSeconds % 60).padStart(2, "0")}`; + return ( + + {isError ? ( + + + {props.presentation.statusLabel} + + + + + + ) : ( + + + + + {elapsedLabel} + + + + + {props.presentation.statusLabel} + + + + )} + + ); +} + +export function ComposerDictationCancelAction(props: { + readonly presentation: VoiceComposerPresentation; + readonly onCancel: () => void; +}) { + if (props.presentation.leadingAction !== "cancel") return null; + return ( + + ); +} + +export function ComposerDictationPrimaryAction(props: { + readonly state: VoiceInputState; + readonly presentation: VoiceComposerPresentation; + readonly isAvailable: boolean; + readonly disabled?: boolean; + readonly onStart: () => void; + readonly onConfirm: () => void; + readonly onCancel: () => void; +}) { + if (props.presentation.trailingAction === "confirm") { + return ( + + ); + } + + return ; +} + +export function ComposerDictationStartAction(props: { + readonly state: VoiceInputState; + readonly isAvailable: boolean; + readonly disabled?: boolean; + readonly onStart: () => void; + readonly onCancel: () => void; +}) { + if (!props.isAvailable) return null; + const openSettings = props.state.phase === "error" && props.state.errorAction === "settings"; + return ( + { + props.onCancel(); + void Linking.openSettings(); + } + : props.onStart + } + /> + ); +} diff --git a/apps/mobile/src/features/voice-input/useVoiceInputController.ts b/apps/mobile/src/features/voice-input/useVoiceInputController.ts new file mode 100644 index 000000000000..2170ff255f8c --- /dev/null +++ b/apps/mobile/src/features/voice-input/useVoiceInputController.ts @@ -0,0 +1,217 @@ +import { + RecordingPresets, + requestRecordingPermissionsAsync, + setAudioModeAsync, + setIsAudioActiveAsync, + useAudioRecorder, + type RecordingStatus, +} from "expo-audio"; +import { File } from "expo-file-system"; +import { useFocusEffect } from "@react-navigation/native"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { AppState } from "react-native"; +import { useSharedValue } from "react-native-reanimated"; + +import type { ComposerEditorSelection } from "../../components/ComposerEditor"; +import { getLocalVoiceTranscriber } from "../../native/voiceTranscription"; +import { + VoiceInputController, + VOICE_RECORDING_LIMIT_SECONDS, + voiceInputBlocksSubmission, + voiceInputFreezesEditor, + type VoiceDraftSnapshot, + type VoiceInputState, +} from "@t3tools/client-runtime/voice-input"; +import { normalizeVoiceInputDecibels, VOICE_WAVEFORM_SAMPLE_COUNT } from "./voiceInputMetering"; + +const INITIAL_STATE: VoiceInputState = { phase: "idle", error: null, errorAction: null }; +const VOICE_METERING_INTERVAL_MS = 80; +const VOICE_RECORDING_OPTIONS = { + ...RecordingPresets.HIGH_QUALITY, + isMeteringEnabled: true, +}; + +async function releaseVoiceRecordingAudio(): Promise { + try { + await setAudioModeAsync({ allowsRecording: false }); + } finally { + // Expo does not deactivate AVAudioSession when recording stops or its + // category changes. Explicit deactivation resumes interrupted app audio. + await setIsAudioActiveAsync(false); + } +} + +async function configureVoiceRecordingAudio(): Promise { + try { + await setAudioModeAsync({ + allowsRecording: true, + interruptionMode: "doNotMix", + playsInSilentMode: true, + shouldPlayInBackground: false, + }); + await setIsAudioActiveAsync(true); + } catch (error) { + try { + await releaseVoiceRecordingAudio(); + } catch { + // Keep the setup error. The controller has not started a recorder yet. + } + throw error; + } +} + +export function useVoiceInputController(input: { + readonly ownerKey: string | null; + readonly draftMessage: string; + readonly selection: ComposerEditorSelection; + readonly disabled?: boolean; + readonly onChangeDraftMessage: (value: string) => void; + readonly onChangeSelection: (selection: ComposerEditorSelection) => void; +}) { + const [state, setState] = useState(INITIAL_STATE); + const [elapsedSeconds, setElapsedSeconds] = useState(0); + const elapsedSecondsRef = useRef(0); + const audioLevelsRef = useRef(Array(VOICE_WAVEFORM_SAMPLE_COUNT).fill(0)); + const audioLevels = useSharedValue(audioLevelsRef.current); + const controllerRef = useRef(null); + const previousDraftRef = useRef({ ownerKey: input.ownerKey, text: input.draftMessage }); + const revisionRef = useRef(0); + if ( + previousDraftRef.current.ownerKey !== input.ownerKey || + previousDraftRef.current.text !== input.draftMessage + ) { + previousDraftRef.current = { ownerKey: input.ownerKey, text: input.draftMessage }; + revisionRef.current += 1; + } + const latestInputRef = useRef(input); + latestInputRef.current = input; + + const handleRecorderStatus = useCallback((status: RecordingStatus) => { + controllerRef.current?.handleRecorderStatus({ + isFinished: status.isFinished, + hasError: status.hasError || status.mediaServicesDidReset === true, + error: status.error, + url: status.url, + }); + }, []); + const recorder = useAudioRecorder(VOICE_RECORDING_OPTIONS, handleRecorderStatus); + + if (!controllerRef.current) { + controllerRef.current = new VoiceInputController({ + recorder, + getTranscriber: getLocalVoiceTranscriber, + requestPermission: async () => { + const permission = await requestRecordingPermissionsAsync(); + return { granted: permission.granted, canAskAgain: permission.canAskAgain }; + }, + configureRecording: configureVoiceRecordingAudio, + releaseRecording: releaseVoiceRecordingAudio, + deleteRecording: (uri) => new File(uri).delete(), + readDraft: (): VoiceDraftSnapshot | null => { + const current = latestInputRef.current; + if (!current.ownerKey) return null; + return { + ownerKey: current.ownerKey, + text: current.draftMessage, + selection: current.selection, + revision: revisionRef.current, + }; + }, + commitDraft: (text, selection) => { + const current = latestInputRef.current; + current.onChangeSelection(selection); + current.onChangeDraftMessage(text); + }, + onStateChange: setState, + }); + } + + const controller = controllerRef.current; + const previousOwnerRef = useRef(input.ownerKey); + useEffect(() => { + if (previousOwnerRef.current === input.ownerKey) return; + previousOwnerRef.current = input.ownerKey; + controller.ownerChanged(); + }, [controller, input.ownerKey]); + + useFocusEffect( + useCallback( + () => () => { + controller.dispose(); + }, + [controller], + ), + ); + + useEffect(() => { + const subscription = AppState.addEventListener("change", (nextState) => { + // iOS reports `inactive` while its permission dialog is open. Only the + // real background state cancels preparation; recorder status handles + // calls and route interruptions during capture. + if (nextState === "background") controller.appMovedToBackground(); + }); + return () => subscription.remove(); + }, [controller]); + + useEffect(() => () => controller.dispose(), [controller]); + + useEffect(() => { + if (state.phase !== "preparing" && state.phase !== "recording") return; + + if (audioLevelsRef.current.some((level) => level !== 0)) { + audioLevelsRef.current = Array(VOICE_WAVEFORM_SAMPLE_COUNT).fill(0); + audioLevels.value = audioLevelsRef.current; + } + if (elapsedSecondsRef.current !== 0) { + elapsedSecondsRef.current = 0; + setElapsedSeconds(0); + } + if (state.phase !== "recording") return; + + const sampleRecording = () => { + if (controller.currentState.phase !== "recording") return; + const status = recorder.getStatus(); + if (!status.isRecording) return; + + const level = normalizeVoiceInputDecibels(status.metering); + const history = audioLevelsRef.current; + if (level !== 0 || history.some((sample) => sample !== 0)) { + const nextLevels = [...history.slice(1), level]; + audioLevelsRef.current = nextLevels; + audioLevels.value = nextLevels; + } + + const nextElapsedSeconds = Math.min( + VOICE_RECORDING_LIMIT_SECONDS, + Math.max(0, Math.floor(status.durationMillis / 1_000)), + ); + if (nextElapsedSeconds !== elapsedSecondsRef.current) { + elapsedSecondsRef.current = nextElapsedSeconds; + setElapsedSeconds(nextElapsedSeconds); + } + }; + + sampleRecording(); + const intervalId = setInterval(sampleRecording, VOICE_METERING_INTERVAL_MS); + return () => clearInterval(intervalId); + }, [audioLevels, controller, recorder, state.phase]); + + const start = useCallback(() => { + if (!latestInputRef.current.disabled) void controller.start(); + }, [controller]); + const stop = useCallback(() => controller.stop(), [controller]); + const cancel = useCallback(() => controller.cancel(), [controller]); + + return { + isAvailable: getLocalVoiceTranscriber() !== null, + state, + audioLevels, + elapsedSeconds, + isBusy: voiceInputBlocksSubmission(state), + freezesEditor: voiceInputFreezesEditor(state), + blocksSubmission: voiceInputBlocksSubmission(state), + start, + stop, + cancel, + }; +} diff --git a/apps/mobile/src/features/voice-input/voiceInputMetering.test.ts b/apps/mobile/src/features/voice-input/voiceInputMetering.test.ts new file mode 100644 index 000000000000..05356eaeeab9 --- /dev/null +++ b/apps/mobile/src/features/voice-input/voiceInputMetering.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { normalizeVoiceInputDecibels } from "./voiceInputMetering"; + +describe("normalizeVoiceInputDecibels", () => { + it.each([undefined, Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY])( + "treats a missing or invalid reading %s as silence", + (decibels) => { + expect(normalizeVoiceInputDecibels(decibels)).toBe(0); + }, + ); + + it.each([-160, -90, -60])("keeps a reading at or below the noise floor %s silent", (decibels) => { + expect(normalizeVoiceInputDecibels(decibels)).toBe(0); + }); + + it("keeps quiet background readings close to the baseline", () => { + const quiet = normalizeVoiceInputDecibels(-50); + expect(quiet).toBeGreaterThan(0); + expect(quiet).toBeLessThan(0.05); + }); + + it("keeps loud negative speech readings distinct below full height", () => { + const levels = [-20, -18, -12, -6, -3].map(normalizeVoiceInputDecibels); + + for (const level of levels) { + expect(level).toBeGreaterThan(0); + expect(level).toBeLessThan(1); + } + expect(levels.every((level, index) => index === 0 || level > levels[index - 1]!)).toBe(true); + }); + + it("makes near-speech changes visible without an early ceiling", () => { + expect(normalizeVoiceInputDecibels(-6) - normalizeVoiceInputDecibels(-12)).toBeGreaterThan( + 0.18, + ); + expect(normalizeVoiceInputDecibels(-3) - normalizeVoiceInputDecibels(-12)).toBeGreaterThan(0.3); + }); + + it("increases throughout the usable microphone range", () => { + const levels = [-60, -55, -50, -40, -30, -20, -12, -6, -3, -0.001, 0].map( + normalizeVoiceInputDecibels, + ); + expect(levels.every((level, index) => index === 0 || level > levels[index - 1]!)).toBe(true); + }); + + it("approaches the noise floor and full scale without a jump", () => { + expect(normalizeVoiceInputDecibels(-59.999)).toBeLessThan(0.001); + expect(normalizeVoiceInputDecibels(-0.001)).toBeGreaterThan(0.999); + expect(normalizeVoiceInputDecibels(-0.001)).toBeLessThan(1); + }); + + it.each([0, 6, 160])("caps only full-scale or higher readings %s at one", (decibels) => { + expect(normalizeVoiceInputDecibels(decibels)).toBe(1); + }); +}); diff --git a/apps/mobile/src/features/voice-input/voiceInputMetering.ts b/apps/mobile/src/features/voice-input/voiceInputMetering.ts new file mode 100644 index 000000000000..06f62fc248ab --- /dev/null +++ b/apps/mobile/src/features/voice-input/voiceInputMetering.ts @@ -0,0 +1,14 @@ +export const VOICE_WAVEFORM_SAMPLE_COUNT = 64; + +const VOICE_NOISE_FLOOR_DECIBELS = -60; +const VOICE_NOISE_FLOOR_AMPLITUDE = 10 ** (VOICE_NOISE_FLOOR_DECIBELS / 20); + +/** Converts measured decibels to compressed amplitude, reserving full height for 0 dB. */ +export function normalizeVoiceInputDecibels(decibels: number | undefined) { + if (decibels === undefined || !Number.isFinite(decibels)) return 0; + if (decibels <= VOICE_NOISE_FLOOR_DECIBELS) return 0; + if (decibels >= 0) return 1; + + const amplitude = 10 ** (decibels / 20); + return Math.sqrt((amplitude - VOICE_NOISE_FLOOR_AMPLITUDE) / (1 - VOICE_NOISE_FLOOR_AMPLITUDE)); +} diff --git a/apps/mobile/src/features/voice-input/voiceInputPresentation.test.ts b/apps/mobile/src/features/voice-input/voiceInputPresentation.test.ts new file mode 100644 index 000000000000..caf160937102 --- /dev/null +++ b/apps/mobile/src/features/voice-input/voiceInputPresentation.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vite-plus/test"; +import { voiceInputFreezesEditor } from "@t3tools/client-runtime/voice-input"; + +import { resolveVoiceComposerPresentation } from "./voiceInputPresentation"; + +describe("resolveVoiceComposerPresentation", () => { + it("maps voice states to stable composer actions and editor read-only state", () => { + expect( + resolveVoiceComposerPresentation({ phase: "idle", error: null, errorAction: null }, 0), + ).toEqual({ + leadingAction: null, + trailingAction: "mic", + showsSend: true, + statusKind: null, + statusLabel: null, + confirmationEnabled: false, + }); + expect( + resolveVoiceComposerPresentation({ phase: "preparing", error: null, errorAction: null }, 0), + ).toMatchObject({ + leadingAction: "cancel", + trailingAction: "confirm", + showsSend: false, + statusLabel: "Preparing", + confirmationEnabled: false, + }); + expect( + resolveVoiceComposerPresentation({ phase: "recording", error: null, errorAction: null }, 64), + ).toMatchObject({ + leadingAction: "cancel", + trailingAction: "confirm", + showsSend: false, + statusLabel: "Recording 1:04", + confirmationEnabled: true, + }); + expect( + resolveVoiceComposerPresentation( + { phase: "transcribing", error: null, errorAction: null }, + 0, + ), + ).toMatchObject({ + statusLabel: "Transcribing", + confirmationEnabled: false, + }); + expect( + resolveVoiceComposerPresentation( + { phase: "error", error: "Microphone unavailable", errorAction: "retry" }, + 0, + ), + ).toMatchObject({ + leadingAction: null, + trailingAction: "mic", + showsSend: true, + statusKind: "error", + statusLabel: "Microphone unavailable", + }); + + expect(voiceInputFreezesEditor({ phase: "preparing", error: null, errorAction: null })).toBe( + true, + ); + expect(voiceInputFreezesEditor({ phase: "recording", error: null, errorAction: null })).toBe( + true, + ); + expect(voiceInputFreezesEditor({ phase: "transcribing", error: null, errorAction: null })).toBe( + true, + ); + expect(voiceInputFreezesEditor({ phase: "idle", error: null, errorAction: null })).toBe(false); + }); +}); diff --git a/apps/mobile/src/features/voice-input/voiceInputPresentation.ts b/apps/mobile/src/features/voice-input/voiceInputPresentation.ts new file mode 100644 index 000000000000..e461e34d6216 --- /dev/null +++ b/apps/mobile/src/features/voice-input/voiceInputPresentation.ts @@ -0,0 +1,65 @@ +import type { VoiceInputState } from "@t3tools/client-runtime/voice-input"; + +export type VoiceComposerPresentation = { + readonly leadingAction: "cancel" | null; + readonly trailingAction: "mic" | "confirm"; + readonly showsSend: boolean; + readonly statusKind: "active" | "error" | null; + readonly statusLabel: string | null; + readonly confirmationEnabled: boolean; +}; + +export function resolveVoiceComposerPresentation( + state: VoiceInputState, + elapsedSeconds: number, +): VoiceComposerPresentation { + switch (state.phase) { + case "idle": + return { + leadingAction: null, + trailingAction: "mic", + showsSend: true, + statusKind: null, + statusLabel: null, + confirmationEnabled: false, + }; + case "error": + return { + leadingAction: null, + trailingAction: "mic", + showsSend: true, + statusKind: "error", + statusLabel: state.error, + confirmationEnabled: false, + }; + case "preparing": + return { + leadingAction: "cancel", + trailingAction: "confirm", + showsSend: false, + statusKind: "active", + statusLabel: "Preparing", + confirmationEnabled: false, + }; + case "recording": { + const seconds = Math.max(0, Math.floor(elapsedSeconds)); + return { + leadingAction: "cancel", + trailingAction: "confirm", + showsSend: false, + statusKind: "active", + statusLabel: `Recording ${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`, + confirmationEnabled: true, + }; + } + case "transcribing": + return { + leadingAction: "cancel", + trailingAction: "confirm", + showsSend: false, + statusKind: "active", + statusLabel: "Transcribing", + confirmationEnabled: false, + }; + } +} diff --git a/apps/mobile/src/lib/attachmentDownload.test.ts b/apps/mobile/src/lib/attachmentDownload.test.ts new file mode 100644 index 000000000000..78e182e74f66 --- /dev/null +++ b/apps/mobile/src/lib/attachmentDownload.test.ts @@ -0,0 +1,400 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const mocks = vi.hoisted(() => ({ + directories: new Set(), + deleted: vi.fn(), + download: vi.fn(), + copy: vi.fn(), + share: vi.fn(), + shareFromSource: vi.fn(), + available: vi.fn(), + uuid: vi.fn(), +})); + +vi.mock("expo-file-system", () => { + class Directory { + readonly uri: string; + + constructor(...parts: Array) { + this.uri = parts.map((part) => (typeof part === "string" ? part : part.uri)).join("/"); + } + + get name(): string { + return this.uri.split("/").at(-1)!; + } + + get exists(): boolean { + return mocks.directories.has(this.uri); + } + + create(): void { + mocks.directories.add(this.uri); + } + + list(): Directory[] { + const prefix = `${this.uri}/`; + return [...mocks.directories] + .filter((uri) => uri.startsWith(prefix) && !uri.slice(prefix.length).includes("/")) + .map((uri) => new Directory(uri)); + } + + delete(): void { + mocks.deleted(this.uri); + mocks.directories.delete(this.uri); + } + } + + class File { + static downloadFileAsync = mocks.download; + readonly uri: string; + + constructor(source: Directory | string, name?: string) { + this.uri = typeof source === "string" ? source : `${source.uri}/${encodeURIComponent(name!)}`; + } + + async copy(destination: File): Promise { + await mocks.copy(this.uri, destination.uri); + } + } + + return { Directory, File, Paths: { cache: "file:///cache" } }; +}); + +vi.mock("expo-sharing", () => ({ + isAvailableAsync: mocks.available, + shareAsync: mocks.share, +})); + +vi.mock("./uuid", () => ({ uuidv4: mocks.uuid })); +vi.mock("./shareFileFromSource", () => ({ shareFileFromSource: mocks.shareFromSource })); + +import { + downloadAndShareAttachment, + downloadAttachmentForPreview, + shareLocalAttachment, +} from "./attachmentDownload"; +import { isForegroundHandoffActive } from "./foreground-handoff"; + +const NOW = 1_787_990_400_000; +const DAY_MS = 24 * 60 * 60_000; +const CACHE = "file:///cache/t3-attachment-downloads"; +const input = { + url: "https://chosen-environment.example/api/assets/signed-token/report.pdf", + attachment: { name: "report.pdf", mimeType: "application/pdf" }, +}; + +beforeEach(() => { + mocks.directories.clear(); + mocks.deleted.mockReset(); + mocks.download.mockReset(); + mocks.copy.mockReset(); + mocks.share.mockReset(); + mocks.shareFromSource.mockReset(); + mocks.available.mockReset(); + mocks.uuid.mockReset(); + mocks.download.mockImplementation(async (_url: string, file: { uri: string }) => file); + mocks.copy.mockResolvedValue(undefined); + mocks.share.mockResolvedValue(undefined); + mocks.shareFromSource.mockResolvedValue(undefined); + mocks.available.mockResolvedValue(true); + let sequence = 0; + mocks.uuid.mockImplementation( + () => `00000000-0000-4000-8000-${String(++sequence).padStart(12, "0")}`, + ); + vi.spyOn(Date, "now").mockReturnValue(NOW); +}); + +afterEach(() => { + vi.restoreAllMocks(); + expect(isForegroundHandoffActive()).toBe(false); +}); + +describe("downloadAndShareAttachment", () => { + it("downloads the chosen environment's signed URL and shares the local file", async () => { + const controller = new AbortController(); + await downloadAndShareAttachment({ ...input, signal: controller.signal }); + + expect(mocks.download).toHaveBeenCalledWith( + input.url, + expect.objectContaining({ uri: expect.stringMatching(/\/report\.pdf$/) }), + { signal: controller.signal }, + ); + expect(mocks.share).toHaveBeenCalledWith( + expect.stringMatching(/^file:\/\/\/cache\/.+\/report\.pdf$/), + { + mimeType: "application/pdf", + dialogTitle: "report.pdf", + }, + ); + expect(mocks.deleted).not.toHaveBeenCalled(); + }); + + it("shares videos even when the server serves their bytes inline", async () => { + await downloadAndShareAttachment({ + url: "https://relay-environment.example/api/assets/signed-video/clip.mp4", + attachment: { name: "clip.mp4", mimeType: 'video/mp4; codecs="avc1"' }, + signal: new AbortController().signal, + }); + + expect(mocks.share).toHaveBeenCalledWith(expect.stringMatching(/\/clip\.mp4$/), { + mimeType: "video/mp4", + dialogTitle: "clip.mp4", + }); + }); + + it.each([ + ["../../résumé.pdf", "résumé.pdf"], + ["C:\\folder\\clip.mp4", "clip.mp4"], + ["a?query#part%2F.txt", "a?query#part%2F.txt"], + ["Report #5 - 100%.pdf", "Report #5 - 100%.pdf"], + ["..", "attachment"], + [" ", "attachment"], + ["\ud800file\u0000.txt", "_file_.txt"], + [".env", ".env"], + ])("uses a safe basename for %j", async (name, expected) => { + await downloadAndShareAttachment({ + ...input, + attachment: { ...input.attachment, name }, + signal: new AbortController().signal, + }); + const file = mocks.download.mock.calls[0]![1] as { uri: string }; + expect(decodeURIComponent(file.uri.split("/").at(-1)!)).toBe(expected); + }); + + it("preserves ordinary long filenames that fit within the filesystem limit", async () => { + const name = + "Project quarterly report with detailed implementation and delivery notes for August 2026.pdf"; + await downloadAndShareAttachment({ + ...input, + attachment: { ...input.attachment, name }, + signal: new AbortController().signal, + }); + const file = mocks.download.mock.calls[0]![1] as { uri: string }; + expect(decodeURIComponent(file.uri.split("/").at(-1)!)).toBe(name); + }); + + it("bounds the UTF-8 filename length while preserving its extension", async () => { + await downloadAndShareAttachment({ + ...input, + attachment: { name: `${"🙂".repeat(80)}.mp4`, mimeType: "video/mp4" }, + signal: new AbortController().signal, + }); + const file = mocks.download.mock.calls[0]![1] as { uri: string }; + const name = decodeURIComponent(file.uri.split("/").at(-1)!); + expect(name.endsWith(".mp4")).toBe(true); + expect(new TextEncoder().encode(name).length).toBeLessThanOrEqual(255); + }); + + it("reports unavailable sharing before downloading or creating files", async () => { + mocks.available.mockResolvedValue(false); + await expect( + downloadAndShareAttachment({ ...input, signal: new AbortController().signal }), + ).rejects.toThrow("Saving and sharing files is unavailable on this device."); + expect(mocks.download).not.toHaveBeenCalled(); + expect(mocks.directories.size).toBe(0); + }); + + it("cleans an interrupted download only after the native request settles", async () => { + const started = Promise.withResolvers(); + const download = Promise.withResolvers<{ uri: string }>(); + mocks.download.mockImplementation(() => { + started.resolve(); + return download.promise; + }); + const controller = new AbortController(); + const task = downloadAndShareAttachment({ ...input, signal: controller.signal }); + await started.promise; + controller.abort(); + expect(mocks.deleted).not.toHaveBeenCalled(); + download.reject(new Error("Canceled native request")); + await task; + expect(mocks.deleted).toHaveBeenCalledTimes(1); + expect(mocks.share).not.toHaveBeenCalled(); + }); + + it("does not open a late download after cancellation", async () => { + const started = Promise.withResolvers<{ uri: string }>(); + const download = Promise.withResolvers<{ uri: string }>(); + mocks.download.mockImplementation((_url: string, file: { uri: string }) => { + started.resolve(file); + return download.promise; + }); + const controller = new AbortController(); + const task = downloadAndShareAttachment({ ...input, signal: controller.signal }); + const file = await started.promise; + controller.abort(); + download.resolve(file); + await task; + expect(mocks.share).not.toHaveBeenCalled(); + expect(mocks.deleted).toHaveBeenCalledTimes(1); + }); + + it("retains an export when its row unmounts during the native handoff", async () => { + const opened = Promise.withResolvers(); + const share = Promise.withResolvers(); + mocks.share.mockImplementation(() => { + expect(isForegroundHandoffActive()).toBe(true); + opened.resolve(); + return share.promise; + }); + const controller = new AbortController(); + const task = downloadAndShareAttachment({ ...input, signal: controller.signal }); + await opened.promise; + controller.abort(); + share.resolve(); + await task; + expect(mocks.deleted).not.toHaveBeenCalled(); + }); + + it("cleans failed exports and releases the foreground handoff", async () => { + mocks.share.mockRejectedValue(new Error("No activity can open this file")); + await expect( + downloadAndShareAttachment({ ...input, signal: new AbortController().signal }), + ).rejects.toThrow("Could not open the share sheet. Try again."); + expect(mocks.deleted).toHaveBeenCalledTimes(1); + }); + + it("removes expired exports while leaving recent and unrelated cache entries alone", async () => { + const old = `${CACHE}/${NOW - DAY_MS - 1}-00000000-0000-4000-8000-000000000010`; + const recent = `${CACHE}/${NOW - DAY_MS + 1}-00000000-0000-4000-8000-000000000011`; + const unrelated = `${CACHE}/unrelated`; + mocks.directories.add(old).add(recent).add(unrelated); + + await downloadAndShareAttachment({ ...input, signal: new AbortController().signal }); + expect(mocks.deleted.mock.calls).toEqual([[old]]); + expect(mocks.directories.has(recent)).toBe(true); + expect(mocks.directories.has(unrelated)).toBe(true); + }); + + it("does not prune an active export even if it passes the cache expiry", async () => { + const opened = Promise.withResolvers(); + const share = Promise.withResolvers(); + mocks.share.mockImplementationOnce(() => { + opened.resolve(); + return share.promise; + }); + const first = downloadAndShareAttachment({ ...input, signal: new AbortController().signal }); + await opened.promise; + vi.mocked(Date.now).mockReturnValue(NOW + DAY_MS + 1); + + await downloadAndShareAttachment({ ...input, signal: new AbortController().signal }); + expect(mocks.deleted).not.toHaveBeenCalled(); + share.resolve(); + await first; + }); +}); + +describe("attachment preview files", () => { + it("does not start a native request after cancellation during setup", async () => { + const controller = new AbortController(); + const loading = downloadAttachmentForPreview({ ...input, signal: controller.signal }); + controller.abort(); + await expect(loading).resolves.toBeNull(); + expect(mocks.download).not.toHaveBeenCalled(); + expect(mocks.share).not.toHaveBeenCalled(); + }); + + it("downloads for playback without requiring a share sheet and removes the file on close", async () => { + mocks.available.mockResolvedValue(false); + const file = await downloadAttachmentForPreview({ + ...input, + signal: new AbortController().signal, + }); + expect(file?.uri.endsWith("/report.pdf")).toBe(true); + expect(mocks.available).not.toHaveBeenCalled(); + expect(mocks.deleted).not.toHaveBeenCalled(); + file?.dispose(); + file?.dispose(); + expect(mocks.deleted).toHaveBeenCalledTimes(1); + }); + + it.each([undefined, "share-button"])( + "keeps a shared preview after its owner closes (source: %s)", + async (sourceIdentifier) => { + const opened = Promise.withResolvers(); + const sharing = Promise.withResolvers(); + const nativeShare = sourceIdentifier ? mocks.shareFromSource : mocks.share; + nativeShare.mockImplementationOnce(() => { + opened.resolve(); + return sharing.promise; + }); + const file = await downloadAttachmentForPreview({ + ...input, + signal: new AbortController().signal, + }); + const share = file!.share(new AbortController().signal, sourceIdentifier); + await opened.promise; + file!.dispose(); + expect(mocks.deleted).not.toHaveBeenCalled(); + expect(isForegroundHandoffActive()).toBe(true); + sharing.resolve(); + await share; + expect(isForegroundHandoffActive()).toBe(false); + expect(mocks.deleted).not.toHaveBeenCalled(); + expect(mocks.download).toHaveBeenCalledTimes(1); + expect(mocks.copy).not.toHaveBeenCalled(); + }, + ); + + it.each([undefined, "share-button"])( + "does not share a disposed preview after availability checking (source: %s)", + async (sourceIdentifier) => { + const checking = Promise.withResolvers(); + const available = Promise.withResolvers(); + mocks.available.mockImplementation(() => { + checking.resolve(); + return available.promise; + }); + const file = await downloadAttachmentForPreview({ + ...input, + signal: new AbortController().signal, + }); + const share = file!.share(new AbortController().signal, sourceIdentifier); + await checking.promise; + file!.dispose(); + available.resolve(true); + await share; + expect(mocks.share).not.toHaveBeenCalled(); + expect(mocks.shareFromSource).not.toHaveBeenCalled(); + expect(mocks.deleted).toHaveBeenCalledTimes(1); + }, + ); + + it("copies a local original before sharing without downloading or deleting the source", async () => { + const uri = "file:///documents/draft/report.pdf"; + await shareLocalAttachment({ + uri, + attachment: input.attachment, + signal: new AbortController().signal, + }); + expect(mocks.copy).toHaveBeenCalledWith( + uri, + expect.stringMatching(/^file:\/\/\/cache\/.+\/report\.pdf$/), + ); + expect(mocks.share).toHaveBeenCalledWith(mocks.copy.mock.calls[0]![1], expect.any(Object)); + expect(mocks.download).not.toHaveBeenCalled(); + expect(mocks.deleted).not.toHaveBeenCalled(); + }); + + it("waits for a local copy to finish before cleaning up a canceled share", async () => { + const copying = Promise.withResolvers(); + const copied = Promise.withResolvers(); + mocks.copy.mockImplementation(() => { + copying.resolve(); + return copied.promise; + }); + const controller = new AbortController(); + const task = shareLocalAttachment({ + uri: "file:///documents/draft/report.pdf", + attachment: input.attachment, + signal: controller.signal, + }); + await copying.promise; + controller.abort(); + expect(mocks.deleted).not.toHaveBeenCalled(); + copied.resolve(); + await task; + expect(mocks.share).not.toHaveBeenCalled(); + expect(mocks.deleted).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/mobile/src/lib/attachmentDownload.ts b/apps/mobile/src/lib/attachmentDownload.ts new file mode 100644 index 000000000000..2ae0c729c190 --- /dev/null +++ b/apps/mobile/src/lib/attachmentDownload.ts @@ -0,0 +1,228 @@ +import type { ChatFileAttachment } from "@t3tools/contracts"; +import type { Directory } from "expo-file-system"; +import type { SharingOptions } from "expo-sharing"; + +import { beginForegroundHandoff } from "./foreground-handoff"; +import { uuidv4 } from "./uuid"; + +const ATTACHMENT_DOWNLOAD_DIRECTORY = "t3-attachment-downloads"; +const DOWNLOAD_RETENTION_MS = 24 * 60 * 60_000; +const DOWNLOAD_DIRECTORY_NAME = /^(\d+)-[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/i; +const activeDirectories = new Set(); + +function downloadFileName(name: string): string { + const basename = name.split(/[\\/]/).at(-1) ?? ""; + const sanitized = Array.from(basename, (character) => { + const codePoint = character.codePointAt(0)!; + return codePoint < 32 || + (codePoint >= 127 && codePoint <= 159) || + (codePoint >= 0xd800 && codePoint <= 0xdfff) + ? "_" + : character; + }) + .join("") + .trim(); + if (!sanitized || /^\.+$/.test(sanitized)) { + return "attachment"; + } + const encoder = new TextEncoder(); + if (encoder.encode(sanitized).byteLength <= 255) { + return sanitized; + } + const extensionMatch = /\.[a-z0-9]{1,16}$/i.exec(sanitized); + const extension = extensionMatch && extensionMatch.index > 0 ? extensionMatch[0] : ""; + const stem = extension ? sanitized.slice(0, -extension.length) : sanitized; + let remainingBytes = 255 - encoder.encode(extension).byteLength; + let shortStem = ""; + for (const character of stem) { + const bytes = encoder.encode(character).byteLength; + if (bytes > remainingBytes) break; + shortStem += character; + remainingBytes -= bytes; + } + return `${shortStem || "attachment"}${extension}`; +} + +function removeDownloadDirectory(directory: Directory): void { + try { + if (directory.exists) { + directory.delete(); + } + } catch (error) { + console.warn("[attachment-downloads] could not remove a cached file", error); + } +} + +type AttachmentFileMetadata = Pick; + +export interface AttachmentPreviewFile { + readonly uri: string; + readonly share: (signal: AbortSignal, sourceIdentifier?: string) => Promise; + readonly dispose: () => void; +} + +async function availableSharing(signal: AbortSignal) { + if (signal.aborted) return null; + const Sharing = await import("expo-sharing"); + const canShare = await Sharing.isAvailableAsync(); + if (signal.aborted) return null; + if (!canShare) { + throw new Error("Saving and sharing files is unavailable on this device."); + } + return Sharing; +} + +async function createCachedAttachmentFile(attachment: AttachmentFileMetadata) { + const { Directory, File, Paths } = await import("expo-file-system"); + const cache = new Directory(Paths.cache, ATTACHMENT_DOWNLOAD_DIRECTORY); + cache.create({ idempotent: true, intermediates: true }); + const now = Date.now(); + try { + for (const entry of cache.list()) { + const match = DOWNLOAD_DIRECTORY_NAME.exec(entry.name); + if ( + entry instanceof Directory && + match && + Number(match[1]) < now - DOWNLOAD_RETENTION_MS && + !activeDirectories.has(entry.uri) + ) { + removeDownloadDirectory(entry); + } + } + } catch (error) { + console.warn("[attachment-downloads] could not inspect cached files", error); + } + + const directory = new Directory(cache, `${now}-${uuidv4()}`); + directory.create(); + let file: InstanceType; + try { + file = new File(directory, downloadFileName(attachment.name)); + } catch (error) { + removeDownloadDirectory(directory); + throw error; + } + activeDirectories.add(directory.uri); + let disposed = false; + let shared = false; + let sharing = false; + const release = () => { + if (!disposed || sharing) return; + activeDirectories.delete(directory.uri); + // A receiver can still be reading after Android's chooser returns. + if (!shared) removeDownloadDirectory(directory); + }; + const preview: AttachmentPreviewFile = { + uri: file.uri, + dispose: () => { + disposed = true; + release(); + }, + share: async (signal, sourceIdentifier) => { + if (disposed || sharing || signal.aborted) return; + sharing = true; + try { + const Sharing = await availableSharing(signal); + if (Sharing === null || disposed) return; + const endHandoff = beginForegroundHandoff(); + try { + const options: SharingOptions = { + mimeType: attachment.mimeType.split(";", 1)[0]?.trim() || "application/octet-stream", + dialogTitle: attachment.name, + }; + if (sourceIdentifier) { + const { shareFileFromSource } = await import("./shareFileFromSource"); + if (signal.aborted || disposed) return; + await shareFileFromSource(file.uri, options, sourceIdentifier); + } else { + await Sharing.shareAsync(file.uri, options); + } + shared = true; + } catch (cause) { + if (!signal.aborted) { + throw new Error("Could not open the share sheet. Try again.", { cause }); + } + } finally { + endHandoff(); + } + } finally { + sharing = false; + release(); + } + }, + }; + return { file, preview }; +} + +/** The caller owns this cached file until disposal, unless it has been shared with another app. */ +export async function downloadAttachmentForPreview(input: { + readonly url: string; + readonly attachment: AttachmentFileMetadata; + readonly signal: AbortSignal; +}): Promise { + if (input.signal.aborted) return null; + const { File } = await import("expo-file-system"); + const cached = await createCachedAttachmentFile(input.attachment); + try { + if (input.signal.aborted) { + cached.preview.dispose(); + return null; + } + await File.downloadFileAsync(input.url, cached.file, { signal: input.signal }); + if (input.signal.aborted) { + cached.preview.dispose(); + return null; + } + return cached.preview; + } catch (cause) { + // Android may leave a partial file after a failed or interrupted request. + cached.preview.dispose(); + if (input.signal.aborted) return null; + throw new Error("Could not download the attachment. Check the connection and try again.", { + cause, + }); + } +} + +/** Downloads original bytes for the native save/share sheet, including inline video responses. */ +export async function downloadAndShareAttachment(input: { + readonly url: string; + readonly attachment: AttachmentFileMetadata; + readonly signal: AbortSignal; + readonly sourceIdentifier?: string; +}): Promise { + if ((await availableSharing(input.signal)) === null) return; + const file = await downloadAttachmentForPreview(input); + if (file === null) return; + try { + await file.share(input.signal, input.sourceIdentifier); + } finally { + file.dispose(); + } +} + +/** Shares a cache copy so another app never relies on the lifetime of a composer draft. */ +export async function shareLocalAttachment(input: { + readonly uri: string; + readonly attachment: AttachmentFileMetadata; + readonly signal: AbortSignal; + readonly sourceIdentifier?: string; +}): Promise { + if ((await availableSharing(input.signal)) === null) return; + const { File } = await import("expo-file-system"); + const cached = await createCachedAttachmentFile(input.attachment); + try { + if (input.signal.aborted) return; + try { + await new File(input.uri).copy(cached.file); + } catch (cause) { + if (input.signal.aborted) return; + throw new Error("Could not prepare the attachment for sharing.", { cause }); + } + if (!input.signal.aborted) { + await cached.preview.share(input.signal, input.sourceIdentifier); + } + } finally { + cached.preview.dispose(); + } +} diff --git a/apps/mobile/src/lib/attachmentUpload.test.ts b/apps/mobile/src/lib/attachmentUpload.test.ts new file mode 100644 index 000000000000..5e8a34dd1cdb --- /dev/null +++ b/apps/mobile/src/lib/attachmentUpload.test.ts @@ -0,0 +1,594 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const mocks = vi.hoisted(() => ({ + documentUri: "file:///documents", + createAssetUrl: vi.fn(), + createUploadUrl: Symbol("create-upload-url"), + executeAtomQuery: vi.fn(), + removeUpload: Symbol("remove-upload"), + preparedConnection: Symbol("prepared-connection"), + runAtomCommand: vi.fn(), + readAtom: vi.fn(), + upload: vi.fn(), + writeFile: vi.fn(), + deleteFile: vi.fn(), +})); + +vi.mock("@t3tools/client-runtime/state/runtime", () => ({ + // The client-runtime attachments module resolves the same file through its + // relative import, so these fakes also feed runAttachmentUploadCycle and + // verifyPersistedAttachmentUpload. + createEnvironmentRpcCommand: () => Symbol("rpc-command"), + executeAtomQuery: mocks.executeAtomQuery, + runAtomCommand: mocks.runAtomCommand, + squashAtomCommandFailure: (result: { readonly error: unknown }) => result.error, +})); + +vi.mock("../state/atom-registry", () => ({ + appAtomRegistry: { get: mocks.readAtom }, +})); + +vi.mock("../state/assets", () => ({ + assetEnvironment: { createUrl: mocks.createAssetUrl }, +})); + +vi.mock("../state/attachments", () => ({ + attachmentEnvironment: { + createUploadUrl: mocks.createUploadUrl, + remove: mocks.removeUpload, + }, +})); + +vi.mock("../state/session", () => ({ + environmentSession: { + preparedConnectionValueAtom: () => mocks.preparedConnection, + }, +})); + +// Cuts the expo-crypto -> react-native import chain out of the test graph. +vi.mock("./uuid", () => ({ + uuidv4: () => "uuid", + randomHex: () => "0000", +})); + +vi.mock("expo-file-system", () => ({ + File: class { + readonly uri: string; + exists = true; + constructor(uri: string, name?: string) { + this.uri = name ? `${uri}/${name}` : uri; + } + create() {} + write(bytes: string, options: unknown) { + mocks.writeFile(this.uri, bytes, options); + } + delete() { + mocks.deleteFile(this.uri); + } + + upload(url: string, options: unknown) { + return mocks.upload(this.uri, url, options); + } + }, + Paths: { + cache: "file:///cache", + get document() { + return { uri: mocks.documentUri }; + }, + }, + UploadType: { BINARY_CONTENT: 0 }, +})); + +import { + prepareTurnAttachments, + releasePendingAttachmentUploads, + withUploadedMobileAttachmentReferences, + validateDraftFileAttachments, +} from "./attachmentUpload"; +import type { DraftComposerAttachment } from "./composerImages"; + +const environmentId = EnvironmentId.make("environment-1"); +const MINTED_ID = "pending-00000000-0000-4000-8000-000000000001-pdf"; + +const image = { + id: "image-1", + type: "image", + name: "screenshot.png", + mimeType: "image/png", + sizeBytes: 3, + dataUrl: "data:image/png;base64,YWJj", + previewUri: "file:///images/screenshot.png", +} as const satisfies DraftComposerAttachment; + +const file = { + id: "file-1", + type: "file", + name: "report.pdf", + mimeType: "application/pdf", + sizeBytes: 42, + fileUri: "file:///documents/report.pdf", +} as const satisfies DraftComposerAttachment; + +describe("validateDraftFileAttachments", () => { + it("allows legacy image-only sends without server config", () => { + expect(validateDraftFileAttachments({ attachments: [image], serverConfig: null })).toBeNull(); + }); + + it("blocks files while config is unknown or file uploads are unsupported", () => { + expect(validateDraftFileAttachments({ attachments: [file], serverConfig: null })).toBe( + "Server attachment support is still loading.", + ); + expect( + validateDraftFileAttachments({ + attachments: [file], + serverConfig: { environment: { capabilities: { attachmentUploads: true } } }, + }), + ).toBe("This server does not support file attachments."); + }); + + it("uses the current clamped limit and allows valid mixed attachments", () => { + const lowerLimit = { + environment: { + capabilities: { + attachmentUploads: true, + fileAttachments: { maxUploadBytes: 20 }, + }, + }, + }; + expect(validateDraftFileAttachments({ attachments: [file], serverConfig: lowerLimit })).toBe( + "'report.pdf' exceeds the 20 bytes attachment limit.", + ); + const allowed = { + environment: { + capabilities: { + attachmentUploads: true, + fileAttachments: { maxUploadBytes: 100 }, + }, + }, + }; + expect( + validateDraftFileAttachments({ attachments: [image, file], serverConfig: allowed }), + ).toBeNull(); + }); +}); + +function removeCallsFor(attachmentId: string): number { + return mocks.runAtomCommand.mock.calls.filter( + ([, command, target]) => + command === mocks.removeUpload && + (target as { input: { attachmentId: string } }).input.attachmentId === attachmentId, + ).length; +} + +describe("prepareTurnAttachments", () => { + beforeEach(() => { + mocks.documentUri = "file:///documents"; + mocks.createAssetUrl.mockReset(); + mocks.createAssetUrl.mockImplementation((target: unknown) => target); + mocks.executeAtomQuery.mockReset(); + mocks.executeAtomQuery.mockResolvedValue({ _tag: "Success", value: {} }); + mocks.runAtomCommand.mockReset(); + mocks.readAtom.mockReset(); + mocks.upload.mockReset(); + mocks.writeFile.mockReset(); + mocks.deleteFile.mockReset(); + mocks.readAtom.mockReturnValue(Option.some({ httpBaseUrl: "https://environment.example/" })); + mocks.runAtomCommand.mockImplementation(async (_registry: unknown, command: unknown) => + command === mocks.createUploadUrl + ? { + _tag: "Success", + value: { + attachmentId: MINTED_ID, + relativeUrl: "/api/attachments/upload/signed", + expiresAt: 1, + }, + } + : { _tag: "Success", value: undefined }, + ); + mocks.upload.mockResolvedValue({ status: 204, body: "", headers: {} }); + }); + + it("keeps existing image attachments on the legacy wire path", async () => { + const prepared = await prepareTurnAttachments({ environmentId, attachments: [image] }); + + expect(prepared.status).toBe("ready"); + if (prepared.status !== "ready") return; + expect(prepared.attachments).toEqual([ + { + type: "image", + name: "screenshot.png", + mimeType: "image/png", + sizeBytes: 3, + dataUrl: "data:image/png;base64,YWJj", + }, + ]); + expect(prepared.pendingAttachmentIds).toEqual([]); + expect(mocks.upload).not.toHaveBeenCalled(); + }); + + it("uploads generic file bytes directly and keeps mixed attachment order", async () => { + const prepared = await prepareTurnAttachments({ environmentId, attachments: [file, image] }); + + expect(mocks.upload).toHaveBeenCalledWith( + "file:///documents/report.pdf", + "https://environment.example/api/attachments/upload/signed", + expect.objectContaining({ + httpMethod: "POST", + uploadType: 0, + headers: { "Content-Type": "application/pdf" }, + }), + ); + expect(prepared.status).toBe("ready"); + if (prepared.status !== "ready") return; + expect(prepared.attachments[0]).toEqual({ + type: "file", + id: MINTED_ID, + name: "report.pdf", + mimeType: "application/pdf", + sizeBytes: 42, + }); + expect(prepared.attachments[1]?.type).toBe("image"); + expect(prepared.pendingAttachmentIds).toEqual([MINTED_ID]); + expect(prepared.draftAttachments[0]).toEqual({ + ...file, + uploadedAttachmentId: MINTED_ID, + uploadEnvironmentId: environmentId, + }); + }); + + it("uses the current connection when an environment reconnects during URL creation", async () => { + mocks.readAtom + .mockReturnValueOnce(Option.some({ httpBaseUrl: "https://old-environment.example/" })) + .mockReturnValueOnce(Option.some({ httpBaseUrl: "https://new-environment.example/" })); + + await prepareTurnAttachments({ environmentId, attachments: [file] }); + + expect(mocks.upload).toHaveBeenCalledWith( + file.fileUri, + "https://new-environment.example/api/attachments/upload/signed", + expect.anything(), + ); + }); + + it("uploads a restored draft file from the current iOS document container", async () => { + const fileName = "33333333-3333-4333-8333-333333333333-report%20%23.pdf"; + const restoredFile = { + ...file, + fileUri: `file:///private/var/mobile/Containers/Data/Application/11111111-1111-4111-8111-111111111111/Documents/t3-composer-attachments/${fileName}`, + }; + mocks.documentUri = + "file:///var/mobile/Containers/Data/Application/22222222-2222-4222-8222-222222222222/Documents"; + const currentUri = `${mocks.documentUri}/t3-composer-attachments/${fileName}`; + mocks.upload.mockImplementation(async (uri: string) => { + if (uri !== currentUri) { + throw new Error("File does not exist in the previous application container."); + } + return { status: 204, body: "", headers: {} }; + }); + + const prepared = await prepareTurnAttachments({ + environmentId, + attachments: [restoredFile], + }); + + expect(prepared.status).toBe("ready"); + expect(mocks.upload).toHaveBeenCalledWith( + currentUri, + "https://environment.example/api/attachments/upload/signed", + expect.anything(), + ); + }); + + it("adds uploaded file references to durable drafts without changing images", () => { + expect( + withUploadedMobileAttachmentReferences({ + environmentId, + attachments: [file, image], + uploadedAttachments: [ + { + type: "file", + id: "pending-existing-pdf", + name: file.name, + mimeType: file.mimeType, + sizeBytes: file.sizeBytes, + }, + { + type: "image", + name: image.name, + mimeType: image.mimeType, + sizeBytes: image.sizeBytes, + dataUrl: image.dataUrl, + }, + ], + }), + ).toEqual([ + { + ...file, + uploadedAttachmentId: "pending-existing-pdf", + uploadEnvironmentId: environmentId, + }, + image, + ]); + }); + + it("reuses a pending file upload from a previous outbox attempt", async () => { + const previouslyUploaded = { + ...file, + uploadedAttachmentId: "pending-existing-pdf", + uploadEnvironmentId: environmentId, + }; + + const prepared = await prepareTurnAttachments({ + environmentId, + attachments: [previouslyUploaded, image], + }); + + expect(prepared.status).toBe("ready"); + if (prepared.status !== "ready") return; + expect(prepared.attachments).toEqual([ + { + type: "file", + id: "pending-existing-pdf", + name: "report.pdf", + mimeType: "application/pdf", + sizeBytes: 42, + }, + { + type: "image", + name: "screenshot.png", + mimeType: "image/png", + sizeBytes: 3, + dataUrl: "data:image/png;base64,YWJj", + }, + ]); + expect(prepared.pendingAttachmentIds).toEqual(["pending-existing-pdf"]); + expect(mocks.upload).not.toHaveBeenCalled(); + expect(mocks.runAtomCommand).not.toHaveBeenCalled(); + }); + + it("uploads a file again when its saved pending upload has expired", async () => { + mocks.executeAtomQuery.mockResolvedValueOnce({ + _tag: "Failure", + error: { _tag: "AssetAttachmentNotFoundError" }, + }); + const previouslyUploaded = { + ...file, + uploadedAttachmentId: "pending-expired-pdf", + uploadEnvironmentId: environmentId, + }; + + const prepared = await prepareTurnAttachments({ + environmentId, + attachments: [previouslyUploaded], + }); + + expect(mocks.upload).toHaveBeenCalledOnce(); + expect(prepared.status).toBe("ready"); + if (prepared.status !== "ready") return; + expect(prepared.pendingAttachmentIds).toEqual([MINTED_ID]); + }); + + it("uploads image bytes over HTTP while retaining the durable offline image", async () => { + const persisted = vi.fn(async () => "persisted" as const); + const prepared = await prepareTurnAttachments({ + environmentId, + attachments: [image], + supportsImageUploads: true, + persistUploadedReferences: persisted, + }); + expect(mocks.writeFile).toHaveBeenCalledWith("file:///cache/t3-upload-uuid", "YWJj", { + encoding: "base64", + }); + expect(mocks.upload).toHaveBeenCalledWith( + "file:///cache/t3-upload-uuid", + "https://environment.example/api/attachments/upload/signed", + expect.objectContaining({ headers: { "Content-Type": "image/png" } }), + ); + expect(mocks.deleteFile).toHaveBeenCalledExactlyOnceWith("file:///cache/t3-upload-uuid"); + expect(prepared.status).toBe("ready"); + if (prepared.status !== "ready") return; + expect(prepared.attachments).toEqual([ + { + type: "image", + id: MINTED_ID, + name: image.name, + mimeType: image.mimeType, + sizeBytes: image.sizeBytes, + }, + ]); + expect(prepared.draftAttachments).toEqual([ + { ...image, uploadedAttachmentId: MINTED_ID, uploadEnvironmentId: environmentId }, + ]); + expect(persisted).toHaveBeenCalledWith(prepared.draftAttachments); + }); + + it("reuses an uploaded image and reuploads its local bytes after server expiry", async () => { + const saved = { + ...image, + uploadedAttachmentId: "saved-image", + uploadEnvironmentId: environmentId, + }; + const reused = await prepareTurnAttachments({ + environmentId, + attachments: [saved], + supportsImageUploads: true, + }); + expect(reused.status === "ready" && reused.attachments[0]).toEqual({ + type: "image", + id: "saved-image", + name: image.name, + mimeType: image.mimeType, + sizeBytes: image.sizeBytes, + }); + expect(mocks.upload).not.toHaveBeenCalled(); + mocks.executeAtomQuery.mockResolvedValueOnce({ + _tag: "Failure", + error: { _tag: "AssetAttachmentNotFoundError" }, + }); + const restored = await prepareTurnAttachments({ + environmentId, + attachments: [saved], + supportsImageUploads: true, + }); + expect(restored.status === "ready" && restored.draftAttachments[0]).toEqual({ + ...saved, + uploadedAttachmentId: MINTED_ID, + }); + expect(mocks.writeFile).toHaveBeenCalledWith("file:///cache/t3-upload-uuid", "YWJj", { + encoding: "base64", + }); + }); + + it("does not reuse an image upload from another environment", async () => { + const prepared = await prepareTurnAttachments({ + environmentId, + attachments: [ + { + ...image, + uploadedAttachmentId: "other-image", + uploadEnvironmentId: EnvironmentId.make("other"), + }, + ], + supportsImageUploads: true, + }); + expect(mocks.executeAtomQuery).not.toHaveBeenCalled(); + expect(mocks.upload).toHaveBeenCalledOnce(); + expect(prepared.status === "ready" && prepared.draftAttachments[0]?.uploadEnvironmentId).toBe( + environmentId, + ); + }); + + it("aborts an active transfer without dropping local bytes or stamping a partial upload", async () => { + const started = Promise.withResolvers(); + const controller = new AbortController(); + const persist = vi.fn(async () => "persisted" as const); + mocks.upload.mockImplementation( + (_uri: string, _url: string, options: { signal: AbortSignal }) => + new Promise((_, reject) => { + options.signal.addEventListener("abort", () => reject(new Error("cancelled")), { + once: true, + }); + started.resolve(); + }), + ); + const preparing = prepareTurnAttachments({ + environmentId, + attachments: [file], + signal: controller.signal, + persistUploadedReferences: persist, + }); + await started.promise; + controller.abort(); + expect(await preparing).toEqual({ status: "abandoned" }); + expect(persist).not.toHaveBeenCalled(); + expect(mocks.deleteFile).not.toHaveBeenCalled(); + expect(removeCallsFor(MINTED_ID)).toBe(1); + }); + + it("removes pending uploads when the native HTTP request fails", async () => { + mocks.upload.mockResolvedValue({ status: 500, body: "failed", headers: {} }); + + await expect(prepareTurnAttachments({ environmentId, attachments: [file] })).rejects.toThrow( + "Upload failed for 'report.pdf' (500).", + ); + expect(removeCallsFor(MINTED_ID)).toBe(1); + }); + + it("keeps a previously persisted upload when a later attachment fails", async () => { + const previouslyUploaded = { + ...file, + id: "file-existing", + uploadedAttachmentId: "pending-existing-pdf", + uploadEnvironmentId: environmentId, + }; + mocks.upload.mockResolvedValue({ status: 500, body: "failed", headers: {} }); + + await expect( + prepareTurnAttachments({ environmentId, attachments: [previouslyUploaded, file] }), + ).rejects.toThrow("Upload failed for 'report.pdf' (500)."); + + expect(removeCallsFor("pending-existing-pdf")).toBe(0); + }); + + it("deletes the minted uploads when the owner abandons the send", async () => { + const result = await prepareTurnAttachments({ + environmentId, + attachments: [file], + persistUploadedReferences: async () => "abandon", + }); + + expect(result.status).toBe("abandoned"); + expect(removeCallsFor(MINTED_ID)).toBe(1); + }); + + it("deletes the minted uploads when persisting the references throws", async () => { + await expect( + prepareTurnAttachments({ + environmentId, + attachments: [file], + persistUploadedReferences: async () => { + throw new Error("draft write failed"); + }, + }), + ).rejects.toThrow("draft write failed"); + + expect(removeCallsFor(MINTED_ID)).toBe(1); + }); + + it("skips persisting when every reference is already stored", async () => { + const previouslyUploaded = { + ...file, + uploadedAttachmentId: "pending-existing-pdf", + uploadEnvironmentId: environmentId, + }; + const persist = vi.fn(async () => "persisted" as const); + + const prepared = await prepareTurnAttachments({ + environmentId, + attachments: [previouslyUploaded], + persistUploadedReferences: persist, + }); + + expect(prepared.status).toBe("ready"); + expect(persist).not.toHaveBeenCalled(); + }); +}); + +describe("releasePendingAttachmentUploads", () => { + beforeEach(() => { + mocks.runAtomCommand.mockReset(); + }); + + it("retries a failed delete once before reporting it", async () => { + mocks.runAtomCommand + .mockResolvedValueOnce({ _tag: "Failure", error: new Error("offline") }) + .mockResolvedValue({ _tag: "Success", value: undefined }); + + await expect( + releasePendingAttachmentUploads(environmentId, ["pending-a"]), + ).resolves.toBeUndefined(); + expect(mocks.runAtomCommand).toHaveBeenCalledTimes(2); + }); + + it("throws when a delete keeps failing so the caller sees the leak", async () => { + mocks.runAtomCommand.mockResolvedValue({ _tag: "Failure", error: new Error("offline") }); + + await expect(releasePendingAttachmentUploads(environmentId, ["pending-a"])).rejects.toThrow( + "pending-a", + ); + }); + + it("treats an already-deleted pending upload as released", async () => { + mocks.runAtomCommand.mockResolvedValue({ + _tag: "Failure", + error: { _tag: "AssetAttachmentNotFoundError" }, + }); + + await expect( + releasePendingAttachmentUploads(environmentId, ["pending-a"]), + ).resolves.toBeUndefined(); + expect(mocks.runAtomCommand).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/mobile/src/lib/attachmentUpload.ts b/apps/mobile/src/lib/attachmentUpload.ts new file mode 100644 index 000000000000..f39329373dd7 --- /dev/null +++ b/apps/mobile/src/lib/attachmentUpload.ts @@ -0,0 +1,381 @@ +import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; +import { + clampFileAttachmentUploadBytes, + fileAttachmentTooLargeMessage, + isAssetAttachmentNotFoundFailure, + runAttachmentUploadCycle, + verifyPersistedAttachmentUpload, +} from "@t3tools/client-runtime/state/attachments"; +import { runAtomCommand, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; +import type { + ChatFileAttachment, + ChatImageAttachment, + EnvironmentId, + UploadChatImageAttachment, +} from "@t3tools/contracts"; +import { PROVIDER_SEND_TURN_SUPPORTED_IMAGE_MIME_TYPES } from "@t3tools/contracts"; +import * as Option from "effect/Option"; + +import { appAtomRegistry } from "../state/atom-registry"; +import { assetEnvironment } from "../state/assets"; +import { attachmentEnvironment } from "../state/attachments"; +import { environmentSession } from "../state/session"; +import { resolveOwnedComposerAttachmentFileUri } from "./composerAttachmentFiles"; +import { toUploadChatImageAttachments, type DraftComposerAttachment } from "./composerImages"; +import { uuidv4 } from "./uuid"; + +/** + * This module owns the server side of a composer attachment's lifecycle. + * `prepareTurnAttachments` acquires pending uploads (verifying and reusing + * persisted ones), hands the uploaded ids back to the attachment's durable + * owner (queued outbox message or composer draft), and returns a release + * handle for after the turn consumed the bytes. Nothing outside this module + * mints or deletes pending uploads. The local-file side of the lifecycle is + * owned by `removeThreadOutboxMessage` / the composer draft mutators, which + * release files through `releaseUnusedComposerAttachmentFiles`. + */ +export type UploadedMobileAttachment = + | UploadChatImageAttachment + | ChatImageAttachment + | ChatFileAttachment; + +export function validateDraftFileAttachments(input: { + readonly attachments: ReadonlyArray; + readonly serverConfig: { + readonly environment: { + readonly capabilities: { + readonly attachmentUploads?: boolean; + readonly fileAttachments?: { readonly maxUploadBytes: number }; + }; + }; + } | null; +}): string | null { + const files = input.attachments.filter((attachment) => attachment.type === "file"); + if (files.length === 0) return null; + if (input.serverConfig === null) return "Server attachment support is still loading."; + const capabilities = input.serverConfig.environment.capabilities; + if (capabilities.attachmentUploads !== true || capabilities.fileAttachments === undefined) { + return "This server does not support file attachments."; + } + const maxBytes = clampFileAttachmentUploadBytes(capabilities.fileAttachments.maxUploadBytes); + const oversized = files.find((attachment) => attachment.sizeBytes > maxBytes); + return oversized ? fileAttachmentTooLargeMessage(oversized.name, maxBytes) : null; +} + +/** Keep uploaded ids alongside the local bytes so a later send can reuse them. */ +export function withUploadedMobileAttachmentReferences(input: { + readonly environmentId: EnvironmentId; + readonly attachments: ReadonlyArray; + readonly uploadedAttachments: ReadonlyArray; +}): ReadonlyArray { + return input.attachments.map((attachment, index) => { + const uploaded = input.uploadedAttachments[index]; + if ( + !uploaded || + !("id" in uploaded) || + attachment.type !== uploaded.type || + (attachment.uploadedAttachmentId === uploaded.id && + attachment.uploadEnvironmentId === input.environmentId) + ) { + return attachment; + } + return { + ...attachment, + uploadedAttachmentId: uploaded.id, + uploadEnvironmentId: input.environmentId, + }; + }); +} + +/** + * Deletes pending uploads the client no longer references. Every delete result + * is inspected; failed deletes are retried once and a persistent failure + * throws, so a caller can never silently leak the outcome. (The server also + * expires pending uploads, so a leaked id self-heals eventually.) + */ +export async function releasePendingAttachmentUploads( + environmentId: EnvironmentId, + attachmentIds: ReadonlyArray, +): Promise { + const deleteOnce = async (attachmentId: string): Promise => { + const result = await runAtomCommand( + appAtomRegistry, + attachmentEnvironment.remove, + { environmentId, input: { attachmentId } }, + { reportFailure: false, reportDefect: false }, + ); + return ( + result._tag === "Success" || + isAssetAttachmentNotFoundFailure(squashAtomCommandFailure(result)) + ); + }; + + const failedAttachmentIds: string[] = []; + for (const attachmentId of attachmentIds) { + if (!(await deleteOnce(attachmentId)) && !(await deleteOnce(attachmentId))) { + failedAttachmentIds.push(attachmentId); + } + } + if (failedAttachmentIds.length > 0) { + throw new Error( + `Could not delete ${failedAttachmentIds.length} pending attachment upload(s): ${failedAttachmentIds.join(", ")}.`, + ); + } +} + +async function releaseCreatedUploadsQuietly( + environmentId: EnvironmentId, + attachmentIds: ReadonlyArray, +): Promise { + try { + await releasePendingAttachmentUploads(environmentId, attachmentIds); + } catch (error) { + // The original failure must propagate; the leaked pending uploads expire + // on the server. + console.warn("[attachments] could not delete abandoned pending uploads", error); + } +} + +export interface PreparedTurnAttachments { + readonly status: "ready"; + /** Wire attachments for `startTurn`, in the original composer order. */ + readonly attachments: ReadonlyArray; + /** Composer attachments annotated with the uploaded pending ids. */ + readonly draftAttachments: ReadonlyArray; + /** Every pending upload backing this turn (reused and newly minted). */ + readonly pendingAttachmentIds: ReadonlyArray; + /** Deletes all pending uploads once the delivered turn holds the bytes. */ + readonly releaseUploads: () => Promise; +} + +export type PrepareTurnAttachmentsResult = + | PreparedTurnAttachments + | { readonly status: "abandoned" }; + +function uploadedReference( + attachment: DraftComposerAttachment, + id: string, +): ChatImageAttachment | ChatFileAttachment { + const fields = { + id, + name: attachment.name, + mimeType: attachment.mimeType, + sizeBytes: attachment.sizeBytes, + }; + return attachment.type === "image" ? { type: "image", ...fields } : { type: "file", ...fields }; +} + +function attachmentUploadInput(attachment: DraftComposerAttachment) { + const fields = { + name: attachment.name, + mimeType: attachment.mimeType, + sizeBytes: attachment.sizeBytes, + }; + if (attachment.type === "file") return { type: "file" as const, ...fields }; + const mimeType = PROVIDER_SEND_TURN_SUPPORTED_IMAGE_MIME_TYPES.find( + (type) => type === attachment.mimeType.toLowerCase(), + ); + if (!mimeType) throw new Error(`Unsupported image type for '${attachment.name}'.`); + return { ...fields, mimeType }; +} + +async function uploadFileBytes( + attachment: DraftComposerAttachment, + url: string, + signal: AbortSignal, + onProgress?: (progress: number) => void, +): Promise { + const { File, Paths, UploadType } = await import("expo-file-system"); + if (signal.aborted) throw new Error("Upload cancelled."); + const file = + attachment.type === "image" + ? new File(Paths.cache, `t3-upload-${uuidv4()}`) + : new File( + resolveOwnedComposerAttachmentFileUri(attachment.fileUri, Paths.document.uri) ?? + attachment.fileUri, + ); + try { + if (attachment.type === "image") { + file.create(); + file.write(attachment.dataUrl.slice(attachment.dataUrl.indexOf(",") + 1), { + encoding: "base64", + }); + } + const result = await file.upload(url, { + httpMethod: "POST", + uploadType: UploadType.BINARY_CONTENT, + headers: { "Content-Type": attachment.mimeType }, + signal, + ...(onProgress + ? { + onProgress: ({ bytesSent, totalBytes }) => { + if (totalBytes > 0) onProgress(bytesSent / totalBytes); + }, + } + : {}), + }); + if (result.status < 200 || result.status >= 300) { + throw new Error(`Upload failed for '${attachment.name}' (${result.status}).`); + } + } finally { + if (attachment.type === "image" && file.exists) file.delete(); + } +} + +/** + * Acquires server-side uploads for one turn's attachments and persists the + * uploaded ids into the attachments' durable owner. + * + * `persistUploadedReferences` runs once the bytes are on the server and only + * when new ids appeared. It must write the annotated attachments into the + * owner (queued message or draft) so a retry after a crash reuses the bytes. + * Returning `"abandon"` (owner no longer wants the send) or throwing deletes + * the pending uploads this call minted, so the owner cannot leak them. + */ +export async function prepareTurnAttachments(input: { + readonly environmentId: EnvironmentId; + readonly attachments: ReadonlyArray; + /** Older environments continue to receive inline images. */ + readonly supportsImageUploads?: boolean; + readonly signal?: AbortSignal; + readonly onUploadProgress?: (attachmentId: string, progress: number) => void; + readonly persistUploadedReferences?: ( + draftAttachments: ReadonlyArray, + ) => Promise<"persisted" | "abandon">; +}): Promise { + const { environmentId } = input; + if (input.signal?.aborted) return { status: "abandoned" }; + const files = input.attachments.filter((attachment) => attachment.type === "file"); + const ready = ( + attachments: ReadonlyArray, + pendingAttachmentIds: ReadonlyArray, + draftAttachments: ReadonlyArray, + ): PreparedTurnAttachments => ({ + status: "ready", + attachments, + draftAttachments, + pendingAttachmentIds, + releaseUploads: () => releasePendingAttachmentUploads(environmentId, pendingAttachmentIds), + }); + + if (input.attachments.length === 0 || (files.length === 0 && !input.supportsImageUploads)) { + return ready( + toUploadChatImageAttachments( + input.attachments.filter((attachment) => attachment.type === "image"), + ), + [], + input.attachments, + ); + } + + const connection = appAtomRegistry.get( + environmentSession.preparedConnectionValueAtom(environmentId), + ); + if (Option.isNone(connection)) { + throw new Error("The environment is not connected."); + } + + const uploadedAttachments: UploadedMobileAttachment[] = []; + const pendingAttachmentIds: string[] = []; + const createdAttachmentIds: string[] = []; + const controller = new AbortController(); + const abort = () => controller.abort(); + input.signal?.addEventListener("abort", abort, { once: true }); + try { + for (const attachment of input.attachments) { + if (controller.signal.aborted) throw new Error("Upload cancelled."); + if (attachment.type === "image" && !input.supportsImageUploads) { + uploadedAttachments.push(...toUploadChatImageAttachments([attachment])); + continue; + } + + // Reuse the bytes from a previous attempt when their pending upload is + // still alive on this environment. + if ( + attachment.uploadEnvironmentId === environmentId && + attachment.uploadedAttachmentId !== undefined + ) { + const verification = await verifyPersistedAttachmentUpload({ + registry: appAtomRegistry, + createAssetUrl: assetEnvironment.createUrl, + environmentId, + attachmentId: attachment.uploadedAttachmentId, + }); + if (verification.status === "failed") { + throw verification.error; + } + if (verification.status === "verified") { + pendingAttachmentIds.push(attachment.uploadedAttachmentId); + uploadedAttachments.push(uploadedReference(attachment, attachment.uploadedAttachmentId)); + continue; + } + // "missing": the pending upload expired, upload the bytes again. + } + + const result = await runAttachmentUploadCycle({ + registry: appAtomRegistry, + createUploadUrl: attachmentEnvironment.createUploadUrl, + remove: attachmentEnvironment.remove, + environmentId, + upload: attachmentUploadInput(attachment), + // Read the connection at transfer time: the environment may have + // reconnected on a new base URL since this cycle started. + resolveUploadUrl: (relativeUrl) => { + const currentConnection = appAtomRegistry.get( + environmentSession.preparedConnectionValueAtom(environmentId), + ); + return Option.isNone(currentConnection) + ? null + : resolveAssetUrl(currentConnection.value.httpBaseUrl, relativeUrl); + }, + transport: (url) => ({ + done: uploadFileBytes( + attachment, + url, + controller.signal, + input.onUploadProgress + ? (progress) => input.onUploadProgress?.(attachment.id, progress) + : undefined, + ), + abort, + }), + onMinted: (attachmentId) => { + if (controller.signal.aborted) return "cancel"; + pendingAttachmentIds.push(attachmentId); + createdAttachmentIds.push(attachmentId); + return "continue"; + }, + }); + if (result.status !== "uploaded") { + throw result.status === "failed" && result.error !== undefined + ? result.error + : new Error(`Upload failed for '${attachment.name}'.`); + } + uploadedAttachments.push(uploadedReference(attachment, result.attachmentId)); + } + + if (controller.signal.aborted) throw new Error("Upload cancelled."); + + const draftAttachments = withUploadedMobileAttachmentReferences({ + environmentId, + attachments: input.attachments, + uploadedAttachments, + }); + const referencesChanged = draftAttachments.some( + (attachment, index) => attachment !== input.attachments[index], + ); + if (referencesChanged && input.persistUploadedReferences) { + if ((await input.persistUploadedReferences(draftAttachments)) === "abandon") { + await releaseCreatedUploadsQuietly(environmentId, createdAttachmentIds); + return { status: "abandoned" }; + } + } + return ready(uploadedAttachments, pendingAttachmentIds, draftAttachments); + } catch (error) { + await releaseCreatedUploadsQuietly(environmentId, createdAttachmentIds); + if (controller.signal.aborted) return { status: "abandoned" }; + throw error; + } finally { + input.signal?.removeEventListener("abort", abort); + } +} diff --git a/apps/mobile/src/lib/authClientMetadata.ts b/apps/mobile/src/lib/authClientMetadata.ts index 5189c34f5806..d706d00fbec4 100644 --- a/apps/mobile/src/lib/authClientMetadata.ts +++ b/apps/mobile/src/lib/authClientMetadata.ts @@ -1,11 +1,22 @@ import type { AuthClientPresentationMetadata } from "@t3tools/contracts"; +import * as Device from "expo-device"; import { Platform } from "react-native"; export function authClientMetadata(appVersion?: string): AuthClientPresentationMetadata { + const osMajorVersion = Number.parseInt(Device.osVersion?.split(".")[0] ?? "", 10); + const deviceModel = Device.modelName?.trim(); + return { label: "T3 Code Mobile", - deviceType: "mobile", + deviceType: + Device.deviceType === Device.DeviceType.TABLET + ? "tablet" + : Device.deviceType === Device.DeviceType.PHONE + ? "mobile" + : "unknown", ...(Platform.OS === "ios" ? { os: "iOS" } : Platform.OS === "android" ? { os: "Android" } : {}), + ...(Number.isFinite(osMajorVersion) && osMajorVersion > 0 ? { osMajorVersion } : {}), + ...(deviceModel ? { deviceModel } : {}), surface: "mobile", ...(appVersion ? { appVersion } : {}), }; diff --git a/apps/mobile/src/lib/composer-image-schema.ts b/apps/mobile/src/lib/composer-image-schema.ts index a121b70ddb5a..3303dad36b0c 100644 --- a/apps/mobile/src/lib/composer-image-schema.ts +++ b/apps/mobile/src/lib/composer-image-schema.ts @@ -1,4 +1,5 @@ import * as Schema from "effect/Schema"; +import { EnvironmentId } from "@t3tools/contracts"; export const DraftComposerImageAttachmentSchema = Schema.Struct({ id: Schema.String, @@ -8,4 +9,22 @@ export const DraftComposerImageAttachmentSchema = Schema.Struct({ mimeType: Schema.String, sizeBytes: Schema.Number, dataUrl: Schema.String, + uploadedAttachmentId: Schema.optional(Schema.String), + uploadEnvironmentId: Schema.optional(EnvironmentId), }); + +export const DraftComposerFileAttachmentSchema = Schema.Struct({ + id: Schema.String, + type: Schema.Literal("file"), + name: Schema.String, + mimeType: Schema.String, + sizeBytes: Schema.Number, + fileUri: Schema.String, + uploadedAttachmentId: Schema.optional(Schema.String), + uploadEnvironmentId: Schema.optional(EnvironmentId), +}); + +export const DraftComposerAttachmentSchema = Schema.Union([ + DraftComposerImageAttachmentSchema, + DraftComposerFileAttachmentSchema, +]); diff --git a/apps/mobile/src/lib/composerAttachmentFiles.test.ts b/apps/mobile/src/lib/composerAttachmentFiles.test.ts new file mode 100644 index 000000000000..8fb71e8eda65 --- /dev/null +++ b/apps/mobile/src/lib/composerAttachmentFiles.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + composerAttachmentFileReferenceKey, + resolveOwnedComposerAttachmentFileUri, +} from "./composerAttachmentFiles"; + +const OLD_CONTAINER = "11111111-1111-4111-8111-111111111111"; +const CURRENT_CONTAINER = "22222222-2222-4222-8222-222222222222"; +const FILE_NAME = "33333333-3333-4333-8333-333333333333-report%20%252F%20%23.pdf"; + +describe("owned attachment paths", () => { + it.each([ + "file:///var/mobile/Containers/Data/Application/", + "file:///Users/dev/Library/Developer/CoreSimulator/Devices/device/data/Containers/Data/Application/", + ])("resolves saved files after an iOS container move under %s", (prefix) => { + const oldUri = `${prefix}${OLD_CONTAINER}/Documents/t3-composer-attachments/${FILE_NAME}`; + const documentUri = `${prefix}${CURRENT_CONTAINER}/Documents/`; + const currentUri = `${documentUri}t3-composer-attachments/${FILE_NAME}`; + + expect(resolveOwnedComposerAttachmentFileUri(oldUri, documentUri)).toBe(currentUri); + expect(composerAttachmentFileReferenceKey(oldUri)).toBe( + composerAttachmentFileReferenceKey(currentUri), + ); + }); + + it("recognizes the private/var alias without changing the stored filename", () => { + const oldUri = `file:///private/var/mobile/Containers/Data/Application/${OLD_CONTAINER}/Documents/t3-composer-attachments/${FILE_NAME}`; + const documentUri = `file:///var/mobile/Containers/Data/Application/${CURRENT_CONTAINER}/Documents/`; + const currentUri = `${documentUri}t3-composer-attachments/${FILE_NAME}`; + + expect(resolveOwnedComposerAttachmentFileUri(oldUri, documentUri)).toBe(currentUri); + expect(composerAttachmentFileReferenceKey(oldUri)).toBe( + composerAttachmentFileReferenceKey(currentUri), + ); + }); + + it.each([ + `file:///private/var/mobile/Containers/Shared/FileProvider/other/Documents/t3-composer-attachments/${FILE_NAME}`, + `file:///var/mobile/Containers/Shared/AppGroup/other/t3-composer-attachments/${FILE_NAME}`, + `file:///var/mobile/Containers/Data/Application/${OLD_CONTAINER}/Documents/report.pdf`, + `file:///var/mobile/Containers/Data/Application/${OLD_CONTAINER}/Documents/t3-composer-attachments/report.pdf`, + `file:///downloads/t3-composer-attachments/${FILE_NAME}`, + `content://shared/t3-composer-attachments/${FILE_NAME}`, + `https://example.com/t3-composer-attachments/${FILE_NAME}`, + `file:///var/mobile/Containers/Data/Application/${OLD_CONTAINER}/Documents/t3-composer-attachments/..%2F..%2Fsender.pdf`, + `file:///var/mobile/Containers/Data/Application/${OLD_CONTAINER}/Documents/t3-composer-attachments/${FILE_NAME}%2Fnested.pdf`, + ])("does not rebase an external or escaped path: %s", (uri) => { + expect( + resolveOwnedComposerAttachmentFileUri( + uri, + `file:///var/mobile/Containers/Data/Application/${CURRENT_CONTAINER}/Documents/`, + ), + ).toBeNull(); + }); +}); diff --git a/apps/mobile/src/lib/composerAttachmentFiles.ts b/apps/mobile/src/lib/composerAttachmentFiles.ts new file mode 100644 index 000000000000..963566b6ad88 --- /dev/null +++ b/apps/mobile/src/lib/composerAttachmentFiles.ts @@ -0,0 +1,107 @@ +export const COMPOSER_ATTACHMENT_DIRECTORY = "t3-composer-attachments"; + +const UUID_PATTERN = "[a-f\\d]{8}-[a-f\\d]{4}-[a-f\\d]{4}-[a-f\\d]{4}-[a-f\\d]{12}"; +const GENERATED_FILE_NAME = new RegExp(`^${UUID_PATTERN}-`, "i"); +const IOS_DOCUMENTS_PATH = new RegExp( + `^(.*/Containers/Data/Application/)${UUID_PATTERN}/Documents$`, + "i", +); +const retainedFiles = new Map(); + +function fileUriPath(uri: string): string | null { + try { + const url = new URL(uri); + if (url.protocol !== "file:" || url.hostname || url.search || url.hash) { + return null; + } + const path = decodeURIComponent(url.pathname); + if (path.includes("\\") || path.includes("\0") || path.split("/").includes("..")) { + return null; + } + return path.startsWith("/private/var/") ? path.slice("/private".length) : path; + } catch { + return null; + } +} + +function ownedFileLocation(uri: string) { + const path = fileUriPath(uri); + if (path === null) { + return null; + } + const separator = `/${COMPOSER_ATTACHMENT_DIRECTORY}/`; + const index = path.lastIndexOf(separator); + const name = index < 0 ? "" : path.slice(index + separator.length); + if (!name || name === "." || name.includes("/")) { + return null; + } + return { documentPath: path.slice(0, index), name }; +} + +/** Compares references across iOS data-container moves without rewriting saved drafts. */ +export function composerAttachmentFileReferenceKey(uri: string): string { + const location = ownedFileLocation(uri); + if (!location) { + return uri; + } + const containerPrefix = GENERATED_FILE_NAME.test(location.name) + ? IOS_DOCUMENTS_PATH.exec(location.documentPath)?.[1] + : undefined; + const documentPath = containerPrefix + ? `${containerPrefix}/Documents` + : location.documentPath; + return `file://${documentPath}/${COMPOSER_ATTACHMENT_DIRECTORY}/${encodeURIComponent(location.name)}`; +} + +/** Holds a local copy until its last player or share-copy operation releases it. */ +export function retainComposerAttachmentFile(uri: string, onLastRelease: () => void): () => void { + const key = composerAttachmentFileReferenceKey(uri); + retainedFiles.set(key, (retainedFiles.get(key) ?? 0) + 1); + let released = false; + return () => { + if (released) { + return; + } + released = true; + const remaining = (retainedFiles.get(key) ?? 1) - 1; + if (remaining > 0) { + retainedFiles.set(key, remaining); + return; + } + retainedFiles.delete(key); + onLastRelease(); + }; +} + +export function isComposerAttachmentFileRetained(uri: string): boolean { + return retainedFiles.has(composerAttachmentFileReferenceKey(uri)); +} + +/** + * Resolves only our saved attachment copies. iOS preserves Documents on updates + * but can change its container UUID. Picker and open-in-place source URIs must + * bypass this resolver so another app's document keeps its original location. + */ +export function resolveOwnedComposerAttachmentFileUri( + uri: string, + documentDirectoryUri: string, +): string | null { + const location = ownedFileLocation(uri); + const documentPath = fileUriPath(documentDirectoryUri)?.replace(/\/+$/, ""); + if (!location || !documentPath) { + return null; + } + if (location.documentPath !== documentPath) { + const currentContainerPrefix = IOS_DOCUMENTS_PATH.exec(documentPath)?.[1]; + if ( + !currentContainerPrefix || + currentContainerPrefix !== IOS_DOCUMENTS_PATH.exec(location.documentPath)?.[1] || + !GENERATED_FILE_NAME.test(location.name) + ) { + return null; + } + } + const resolved = new URL(documentDirectoryUri); + resolved.pathname = `${resolved.pathname.replace(/\/+$/, "")}/${COMPOSER_ATTACHMENT_DIRECTORY}/${encodeURIComponent(location.name)}`; + return resolved.href; +} diff --git a/apps/mobile/src/lib/composerAttachmentUploadQueue.test.ts b/apps/mobile/src/lib/composerAttachmentUploadQueue.test.ts new file mode 100644 index 000000000000..6b040b698e3d --- /dev/null +++ b/apps/mobile/src/lib/composerAttachmentUploadQueue.test.ts @@ -0,0 +1,258 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { + composerAttachmentUploadBlockReason, + composerAttachmentUploadKey, + composerDraftEnvironmentId, + createComposerAttachmentUploadQueue, + type ComposerAttachmentUploadRequest, + type ComposerAttachmentUploadState, +} from "./composerAttachmentUploadQueue"; + +const environmentId = EnvironmentId.make("environment-1"); +function request(id: string, environment = environmentId): ComposerAttachmentUploadRequest { + return { + environmentId: environment, + attachment: { + id, + type: "file", + name: `${id}.pdf`, + mimeType: "application/pdf", + sizeBytes: 42, + fileUri: `file:///documents/${id}.pdf`, + }, + }; +} + +describe("composer attachment upload queue", () => { + it("bounds concurrency, deduplicates updates, and drains all attachments", async () => { + const gates = new Map>>(); + const fourthStarted = Promise.withResolvers(); + const firstThreeStarted = Promise.withResolvers(); + let active = 0; + let maximum = 0; + const upload = vi.fn(async (input: ComposerAttachmentUploadRequest) => { + active += 1; + maximum = Math.max(maximum, active); + const gate = Promise.withResolvers(); + gates.set(input.attachment.id, gate); + if (gates.size === 3) firstThreeStarted.resolve(); + if (gates.size === 4) fourthStarted.resolve(); + try { + return await gate.promise; + } finally { + active -= 1; + } + }); + const queue = createComposerAttachmentUploadQueue({ upload, onChange: () => {} }); + const requests = [request("one"), request("two"), request("three"), request("four")]; + queue.sync(requests); + queue.sync(requests); + await firstThreeStarted.promise; + expect(upload).toHaveBeenCalledTimes(3); + gates.get("one")!.resolve(true); + await fourthStarted.promise; + for (const gate of gates.values()) gate.resolve(true); + await queue.settled(); + queue.sync(requests); + await queue.settled(); + expect(maximum).toBe(3); + expect(upload).toHaveBeenCalledTimes(4); + queue.dispose(); + }); + + it("cancels on disconnect and resumes from the same local draft on reconnect", async () => { + const started = Promise.withResolvers(); + let states: Readonly> = {}; + let signal: AbortSignal | undefined; + const upload = vi.fn( + async (_request: ComposerAttachmentUploadRequest, currentSignal: AbortSignal) => { + signal = currentSignal; + started.resolve(); + return new Promise((resolve) => + currentSignal.addEventListener("abort", () => resolve(false), { once: true }), + ); + }, + ); + const queue = createComposerAttachmentUploadQueue({ + upload, + onChange: (next) => { + states = next; + }, + }); + const local = request("offline-draft"); + queue.sync([local]); + await started.promise; + queue.sync([]); + await queue.settled(); + expect(signal?.aborted).toBe(true); + expect(states).toEqual({}); + upload.mockResolvedValueOnce(true); + queue.sync([local]); + await queue.settled(); + expect(upload.mock.calls[1]?.[0]).toBe(local); + expect(states[composerAttachmentUploadKey(environmentId, local.attachment.id)]).toEqual({ + status: "ready", + }); + expect(local.attachment).toMatchObject({ fileUri: "file:///documents/offline-draft.pdf" }); + queue.dispose(); + }); + + it("ignores a late completion after removal or environment switch", async () => { + const gate = Promise.withResolvers(); + const started = Promise.withResolvers(); + let states: Readonly> = {}; + const upload = vi.fn(async () => { + started.resolve(); + return gate.promise; + }); + const queue = createComposerAttachmentUploadQueue({ + upload, + onChange: (next) => { + states = next; + }, + }); + queue.sync([request("photo")]); + await started.promise; + upload.mockResolvedValueOnce(true); + const other = EnvironmentId.make("environment-2"); + queue.sync([request("photo", other)]); + gate.resolve(true); + await queue.settled(); + expect(states).toEqual({ [composerAttachmentUploadKey(other, "photo")]: { status: "ready" } }); + queue.sync([]); + expect(states).toEqual({}); + queue.dispose(); + }); + + it("restarts a re-added attachment after its aborted transfer finishes settling", async () => { + const firstStarted = Promise.withResolvers(); + const firstSettled = Promise.withResolvers(); + const secondStarted = Promise.withResolvers(); + const secondSettled = Promise.withResolvers(); + let states: Readonly> = {}; + let firstSignal: AbortSignal | undefined; + const upload = vi.fn(async (_request: ComposerAttachmentUploadRequest, signal: AbortSignal) => { + if (!firstSignal) { + firstSignal = signal; + firstStarted.resolve(); + return firstSettled.promise; + } + secondStarted.resolve(); + return secondSettled.promise; + }); + const queue = createComposerAttachmentUploadQueue({ + upload, + onChange: (next) => { + states = next; + }, + }); + const local = request("re-added"); + queue.sync([local]); + await firstStarted.promise; + queue.sync([]); + queue.sync([local]); + expect(firstSignal?.aborted).toBe(true); + expect(upload).toHaveBeenCalledOnce(); + firstSettled.resolve(false); + await secondStarted.promise; + expect(upload).toHaveBeenCalledTimes(2); + secondSettled.resolve(true); + await queue.settled(); + expect(states[composerAttachmentUploadKey(environmentId, local.attachment.id)]).toEqual({ + status: "ready", + }); + queue.dispose(); + }); + + it("keeps failures stable until retry and reports bounded progress", async () => { + let states: Readonly> = {}; + const progress: number[] = []; + const upload = vi.fn( + async ( + _request: ComposerAttachmentUploadRequest, + _signal: AbortSignal, + report: (value: number) => void, + ): Promise => { + report(0.12); + report(0.13); + report(1.1); + throw new Error("Server unavailable"); + }, + ); + const queue = createComposerAttachmentUploadQueue({ + upload, + onChange: (next) => { + states = next; + const state = next[composerAttachmentUploadKey(environmentId, "file")]; + if (state?.status === "uploading") progress.push(state.progress); + }, + }); + queue.sync([request("file")]); + await queue.settled(); + queue.sync([request("file")]); + expect(upload).toHaveBeenCalledOnce(); + expect(states[composerAttachmentUploadKey(environmentId, "file")]).toEqual({ + status: "failed", + reason: "Server unavailable", + }); + expect(progress).toEqual([0, 0.1, 1]); + upload.mockImplementationOnce(async () => true); + queue.retry(environmentId, "file"); + await queue.settled(); + expect(states[composerAttachmentUploadKey(environmentId, "file")]).toEqual({ status: "ready" }); + queue.dispose(); + }); + + it("does not spin when an upload's draft was abandoned before persistence", async () => { + const upload = vi.fn(async () => false); + const queue = createComposerAttachmentUploadQueue({ upload, onChange: () => {} }); + queue.sync([request("discarded")]); + await queue.settled(); + expect(upload).toHaveBeenCalledOnce(); + queue.dispose(); + }); +}); + +describe("draft upload scope and offline submission", () => { + it("resolves thread, new-task, and queued-task drafts without crossing environments", () => { + expect(composerDraftEnvironmentId("environment-1:thread", [])).toBe(environmentId); + expect(composerDraftEnvironmentId("new-task:environment-1:project", [])).toBe(environmentId); + expect( + composerDraftEnvironmentId("pending-task:message", [{ messageId: "message", environmentId }]), + ).toBe(environmentId); + expect(composerDraftEnvironmentId("pending-task:missing", [])).toBeNull(); + const colonEnvironment = EnvironmentId.make("a:vcs-status:b"); + expect(composerDraftEnvironmentId(`${colonEnvironment}:thread`, [])).toBe(colonEnvironment); + expect(composerDraftEnvironmentId(`new-task:${colonEnvironment}:project`, [])).toBe( + colonEnvironment, + ); + }); + + it("allows offline queuing while a connected composer waits for upload or retry", () => { + const key = composerAttachmentUploadKey(environmentId, "file"); + const input = { + environmentId, + attachments: [request("file").attachment], + connected: true, + serverConfig: { + environment: { + capabilities: { attachmentUploads: true, fileAttachments: { maxUploadBytes: 1024 } }, + }, + }, + states: {}, + }; + expect(composerAttachmentUploadBlockReason(input)).toBe("Attachment still uploading"); + expect(composerAttachmentUploadBlockReason({ ...input, connected: false })).toBeNull(); + expect( + composerAttachmentUploadBlockReason({ + ...input, + states: { [key]: { status: "failed", reason: "Offline" } }, + }), + ).toBe("Retry or remove the failed attachment"); + expect( + composerAttachmentUploadBlockReason({ ...input, states: { [key]: { status: "ready" } } }), + ).toBeNull(); + }); +}); diff --git a/apps/mobile/src/lib/composerAttachmentUploadQueue.ts b/apps/mobile/src/lib/composerAttachmentUploadQueue.ts new file mode 100644 index 000000000000..071afefa4c7d --- /dev/null +++ b/apps/mobile/src/lib/composerAttachmentUploadQueue.ts @@ -0,0 +1,193 @@ +import { EnvironmentId, type ServerConfig } from "@t3tools/contracts"; +import { clampFileAttachmentUploadBytes } from "@t3tools/client-runtime/state/attachments"; + +import type { DraftComposerAttachment } from "./composerImages"; + +export interface ComposerAttachmentUploadRequest { + readonly environmentId: EnvironmentId; + readonly attachment: DraftComposerAttachment; +} + +export type ComposerAttachmentUploadState = + | { readonly status: "uploading"; readonly progress: number } + | { readonly status: "ready" } + | { readonly status: "failed"; readonly reason: string }; + +export function composerAttachmentUploadKey( + environmentId: EnvironmentId, + attachmentId: string, +): string { + return `${environmentId}:${attachmentId}`; +} + +export function composerDraftEnvironmentId( + draftKey: string, + queuedMessages: ReadonlyArray<{ + readonly messageId: string; + readonly environmentId: EnvironmentId; + }>, +): EnvironmentId | null { + if (draftKey.startsWith("pending-task:")) { + return ( + queuedMessages.find((message) => `pending-task:${message.messageId}` === draftKey) + ?.environmentId ?? null + ); + } + const scope = draftKey.startsWith("new-task:") ? draftKey.slice("new-task:".length) : draftKey; + const separator = scope.lastIndexOf(":"); + return separator > 0 ? EnvironmentId.make(scope.slice(0, separator)) : null; +} + +type UploadServerConfig = { + readonly environment: { + readonly capabilities: Pick< + ServerConfig["environment"]["capabilities"], + "attachmentUploads" | "fileAttachments" + >; + }; +}; + +export function canUploadComposerAttachment( + attachment: DraftComposerAttachment, + config: UploadServerConfig | null | undefined, +): boolean { + const capabilities = config?.environment.capabilities; + return ( + capabilities?.attachmentUploads === true && + (attachment.type === "image" || + (capabilities.fileAttachments !== undefined && + attachment.sizeBytes <= + clampFileAttachmentUploadBytes(capabilities.fileAttachments.maxUploadBytes))) + ); +} + +export function composerAttachmentUploadBlockReason(input: { + readonly environmentId: EnvironmentId; + readonly attachments: ReadonlyArray; + readonly connected: boolean; + readonly serverConfig: UploadServerConfig | null; + readonly states: Readonly>; +}): string | null { + if (!input.connected) return null; + for (const attachment of input.attachments) { + if (!canUploadComposerAttachment(attachment, input.serverConfig)) continue; + const state = input.states[composerAttachmentUploadKey(input.environmentId, attachment.id)]; + if (state?.status === "failed") return "Retry or remove the failed attachment"; + if (state?.status !== "ready") return "Attachment still uploading"; + } + return null; +} + +/** Bounds transfers across environments; disconnected or discarded drafts keep their local bytes. */ +export function createComposerAttachmentUploadQueue(options: { + readonly upload: ( + request: ComposerAttachmentUploadRequest, + signal: AbortSignal, + onProgress: (progress: number) => void, + ) => Promise; + readonly onChange: (states: Readonly>) => void; +}) { + const jobs = new Map< + string, + { readonly controller: AbortController; readonly done: Promise } + >(); + let desired = new Map(); + let states: Readonly> = {}; + let disposed = false; + + function setState(key: string, state: ComposerAttachmentUploadState | undefined) { + const previous = states[key]; + if ( + previous === state || + (previous?.status === "uploading" && + state?.status === "uploading" && + previous.progress === state.progress) + ) + return; + const next = { ...states }; + if (state) next[key] = state; + else delete next[key]; + states = next; + options.onChange(states); + } + + function pump() { + if (disposed) return; + for (const [key, request] of desired) { + if (jobs.size >= 3) break; + if (jobs.has(key) || states[key]?.status === "ready" || states[key]?.status === "failed") + continue; + const controller = new AbortController(); + setState(key, { status: "uploading", progress: 0 }); + // Publish the job before starting async work, including synchronous test transports. + const done = Promise.resolve() + .then(() => + options.upload(request, controller.signal, (progress) => { + if (controller.signal.aborted) return; + setState(key, { + status: "uploading", + progress: Math.floor(Math.max(0, Math.min(1, progress)) * 20) / 20, + }); + }), + ) + .then((persisted) => { + if (!controller.signal.aborted && desired.has(key)) { + if (!persisted) desired.delete(key); + setState(key, persisted ? { status: "ready" } : undefined); + } + }) + .catch((error: unknown) => { + if (!controller.signal.aborted && desired.has(key)) { + setState(key, { + status: "failed", + reason: error instanceof Error ? error.message : "Upload failed. Tap to retry.", + }); + } + }) + .finally(() => { + jobs.delete(key); + pump(); + }); + jobs.set(key, { controller, done }); + } + } + + return { + sync(requests: ReadonlyArray) { + if (disposed) return; + desired = new Map( + requests.map((request) => [ + composerAttachmentUploadKey(request.environmentId, request.attachment.id), + request, + ]), + ); + for (const [key, job] of jobs) { + if (!desired.has(key)) job.controller.abort(); + } + for (const key of Object.keys(states)) { + if (!desired.has(key)) setState(key, undefined); + } + for (const key of desired.keys()) { + if (!states[key]) setState(key, { status: "uploading", progress: 0 }); + } + pump(); + }, + retry(environmentId: EnvironmentId, attachmentId: string) { + const key = composerAttachmentUploadKey(environmentId, attachmentId); + if (states[key]?.status !== "failed") return; + setState(key, undefined); + pump(); + }, + /** Waits for the current transfers, useful for shutdown and focused verification. */ + async settled() { + while (jobs.size > 0) await Promise.all([...jobs.values()].map((job) => job.done)); + }, + dispose() { + disposed = true; + desired.clear(); + for (const job of jobs.values()) job.controller.abort(); + states = {}; + options.onChange(states); + }, + }; +} diff --git a/apps/mobile/src/lib/composerFiles.test.ts b/apps/mobile/src/lib/composerFiles.test.ts new file mode 100644 index 000000000000..b38c0813c6a1 --- /dev/null +++ b/apps/mobile/src/lib/composerFiles.test.ts @@ -0,0 +1,812 @@ +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { PROVIDER_SEND_TURN_MAX_IMAGE_BYTES } from "@t3tools/contracts"; +import type { ImagePickerAsset } from "expo-image-picker"; + +const mocks = vi.hoisted(() => ({ + documentUri: "file:///documents", + pickFile: vi.fn(), + pickMedia: vi.fn(), + copy: vi.fn(), + delete: vi.fn(), + open: vi.fn(), + size: vi.fn(), + readBase64: vi.fn(), +})); + +vi.mock("expo-file-system", () => { + class Directory { + readonly uri: string; + + constructor(root: string | { readonly uri: string }, name: string) { + this.uri = `${typeof root === "string" ? root : root.uri}/${name}`; + } + + create(): void {} + } + + class File { + readonly uri: string; + + constructor(source: string | Directory, name?: string) { + this.uri = source instanceof Directory ? `${source.uri}/${name}` : source; + } + + get exists(): boolean { + return true; + } + + get size(): number | null { + return mocks.size(this.uri) ?? null; + } + + get name(): string { + return this.uri.split("/").at(-1) ?? ""; + } + + get type(): string { + return "video/quicktime"; + } + + create(): void {} + + open(mode: string) { + return mocks.open(this.uri, mode); + } + + async copy(destination: File): Promise { + mocks.copy(this.uri, destination.uri); + } + + async base64(): Promise { + return mocks.readBase64(this.uri); + } + + delete(): void { + mocks.delete(this.uri); + } + } + + return { + Directory, + File, + FileMode: { ReadOnly: "r", WriteOnly: "w" }, + Paths: { + get document() { + return { uri: mocks.documentUri }; + }, + }, + }; +}); + +vi.mock("expo-image-picker", () => ({ launchImageLibraryAsync: mocks.pickMedia })); +vi.mock("expo-document-picker", () => ({ getDocumentAsync: mocks.pickFile })); +vi.mock("./uuid", () => ({ uuidv4: () => "attachment-id" })); + +import { + persistComposerAttachmentFile, + pickComposerFiles, + pickComposerImages, + pickComposerMedia, + removePersistedComposerAttachmentFile, +} from "./composerImages"; +import { isForegroundHandoffActive } from "./foreground-handoff"; +import { retainComposerAttachmentFile } from "./composerAttachmentFiles"; + +describe("composer file attachments", () => { + beforeEach(() => { + mocks.documentUri = "file:///documents"; + mocks.pickFile.mockReset(); + mocks.pickMedia.mockReset(); + mocks.copy.mockReset(); + mocks.delete.mockReset(); + mocks.open.mockReset(); + mocks.size.mockReset(); + mocks.readBase64.mockReset(); + mocks.size.mockImplementation((uri: string) => (uri.startsWith("content:") ? null : 42)); + }); + + describe("photo library image conversion", () => { + const jpeg = "/9j/2Q=="; + const photo: ImagePickerAsset = { + uri: "file:///picker/photo.heic", + type: "image", + fileName: "photo.HEIC", + mimeType: "image/heic", + fileSize: 20 * 1024 * 1024, + base64: jpeg, + width: 1, + height: 1, + }; + + it.each(["image/heic", "image/heif", undefined])( + "attaches the native JPEG conversion with matching metadata when the source MIME is %s", + async (mimeType) => { + mocks.pickMedia.mockResolvedValue({ + canceled: false, + assets: [{ ...photo, mimeType }], + }); + + const result = await pickComposerImages({ existingCount: 0 }); + + expect(result).toEqual({ + images: [ + { + id: "attachment-id", + type: "image", + name: "photo.jpg", + mimeType: "image/jpeg", + sizeBytes: 4, + dataUrl: `data:image/jpeg;base64,${jpeg}`, + previewUri: `data:image/jpeg;base64,${jpeg}`, + }, + ], + error: null, + }); + }, + ); + + it.each([ + { extension: "png", mimeType: "image/png", base64: "iVBORw0KGgo=" }, + { extension: "gif", mimeType: "image/gif", base64: "R0lGODlh" }, + { extension: "webp", mimeType: "image/webp", base64: "UklGRgQAAABXRUJQ" }, + ])("preserves original $extension bytes instead of the picker's JPEG", async (original) => { + const name = `photo.${original.extension}`; + mocks.pickMedia.mockResolvedValue({ + canceled: false, + assets: [{ ...photo, fileName: name, mimeType: original.mimeType }], + }); + mocks.readBase64.mockResolvedValue(original.base64); + + const result = await pickComposerImages({ existingCount: 0 }); + + expect(result.error).toBeNull(); + expect(result.images).toEqual([ + expect.objectContaining({ + name, + mimeType: original.mimeType, + dataUrl: `data:${original.mimeType};base64,${original.base64}`, + sizeBytes: Buffer.from(original.base64, "base64").byteLength, + }), + ]); + }); + + it("checks the converted JPEG size even when the HEIC source was smaller", async () => { + const oversized = + jpeg.slice(0, 4) + "A".repeat(Math.ceil(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / 3) * 4); + mocks.pickMedia.mockResolvedValue({ + canceled: false, + assets: [{ ...photo, fileSize: 42, base64: oversized }], + }); + + await expect(pickComposerImages({ existingCount: 0 })).resolves.toEqual({ + images: [], + error: "'photo.HEIC' exceeds the 10 MB attachment limit.", + }); + }); + + it("does not relabel unconverted HEIC bytes as JPEG", async () => { + mocks.pickMedia.mockResolvedValue({ + canceled: false, + assets: [{ ...photo, base64: "AAAAGGZ0eXBoZWlj" }], + }); + + const result = await pickComposerImages({ existingCount: 0 }); + + expect(result.images).toEqual([]); + expect(result.error).toContain("not a supported image type"); + }); + + it("retains a converted photo when another original cannot be read", async () => { + mocks.pickMedia.mockResolvedValue({ + canceled: false, + assets: [{ ...photo, fileName: "missing.gif", mimeType: "image/gif" }, photo], + }); + mocks.readBase64.mockRejectedValue(new Error("missing file")); + + const result = await pickComposerImages({ existingCount: 0 }); + + expect(result.images).toEqual([expect.objectContaining({ name: "photo.jpg" })]); + expect(result.error).toBe("Failed to read 'missing.gif'."); + }); + }); + + describe("photo library videos", () => { + const image: ImagePickerAsset = { + uri: "file:///picker/photo.png", + type: "image", + fileName: "photo.png", + mimeType: "image/png", + fileSize: 3, + base64: "YWJj", + width: 1, + height: 1, + }; + const video: ImagePickerAsset = { + uri: "file:///picker/clip.mov", + type: "video", + fileName: "clip.mov", + mimeType: "video/quicktime", + fileSize: 20 * 1024 * 1024, + base64: null, + width: 1920, + height: 1080, + }; + + it("retains mixed photos and videos, keeping video bytes in durable file storage", async () => { + mocks.pickMedia.mockResolvedValue({ canceled: false, assets: [image, video] }); + mocks.size.mockReturnValue(video.fileSize); + + const result = await pickComposerMedia({ existingCount: 0, maxVideoBytes: 50 * 1024 * 1024 }); + + expect(mocks.pickMedia).toHaveBeenCalledWith( + expect.objectContaining({ + mediaTypes: ["images", "videos"], + shouldDownloadFromNetwork: true, + }), + ); + expect(result).toEqual({ + attachments: [ + expect.objectContaining({ type: "image", dataUrl: "data:image/png;base64,YWJj" }), + { + id: "attachment-id", + type: "file", + name: "clip.mov", + mimeType: "video/quicktime", + sizeBytes: video.fileSize, + fileUri: "file:///documents/t3-composer-attachments/attachment-id-clip.mov", + }, + ], + error: null, + }); + expect(mocks.copy).toHaveBeenCalledWith( + video.uri, + "file:///documents/t3-composer-attachments/attachment-id-clip.mov", + ); + expect(mocks.delete).not.toHaveBeenCalled(); + }); + + it("keeps image-only destinations on the image picker path", async () => { + mocks.pickMedia.mockResolvedValue({ canceled: false, assets: [image] }); + + const result = await pickComposerImages({ existingCount: 0 }); + + expect(mocks.pickMedia).toHaveBeenCalledWith( + expect.objectContaining({ mediaTypes: ["images"] }), + ); + expect(result.images).toEqual([ + expect.objectContaining({ type: "image", name: "photo.png" }), + ]); + expect(result.error).toBeNull(); + expect(mocks.copy).not.toHaveBeenCalled(); + }); + + it("does not persist videos when the destination lacks file support", async () => { + mocks.pickMedia.mockResolvedValue({ canceled: false, assets: [video, image] }); + + const result = await pickComposerMedia({ existingCount: 0 }); + + expect(result.attachments).toEqual([expect.objectContaining({ type: "image" })]); + expect(result.error).toBe("Video attachments are unavailable here."); + expect(mocks.copy).not.toHaveBeenCalled(); + }); + + it("uses local video metadata when the picker omits its name, MIME type, or size", async () => { + mocks.pickMedia.mockResolvedValue({ + canceled: false, + assets: [{ ...video, fileName: null, mimeType: undefined, fileSize: undefined }], + }); + + const result = await pickComposerMedia({ existingCount: 0, maxVideoBytes: 1024 }); + + expect(result.error).toBeNull(); + expect(result.attachments).toEqual([ + expect.objectContaining({ + type: "file", + name: "clip.mov", + mimeType: "video/quicktime", + sizeBytes: 42, + }), + ]); + }); + + it.each([ + { + reason: "picker size exceeds the server limit", + reported: 2 * 1024 * 1024, + stored: 42, + limit: 1024 * 1024, + error: "'clip.mov' exceeds the 1 MB attachment limit.", + }, + { + reason: "actual size exceeds the server limit", + reported: 42, + stored: 2 * 1024 * 1024, + limit: 1024 * 1024, + error: "'clip.mov' exceeds the 1 MB attachment limit.", + }, + { + reason: "stored copy is empty", + reported: 42, + stored: 0, + limit: 1024 * 1024, + error: "'clip.mov' is empty or could not be read.", + }, + { + reason: "server advertises more than the contract limit", + reported: 51 * 1024 * 1024, + stored: 42, + limit: 80 * 1024 * 1024, + error: "'clip.mov' exceeds the 50 MB attachment limit.", + }, + ])( + "rejects a video when $reason while retaining the selected photo", + async ({ reported, stored, limit, error }) => { + mocks.pickMedia.mockResolvedValue({ + canceled: false, + assets: [{ ...video, fileSize: reported }, image], + }); + mocks.size.mockReturnValue(stored); + + const result = await pickComposerMedia({ existingCount: 0, maxVideoBytes: limit }); + + expect(result).toEqual({ + attachments: [expect.objectContaining({ type: "image" })], + error, + }); + if (stored === 0) { + expect(mocks.delete).toHaveBeenCalledWith( + "file:///documents/t3-composer-attachments/attachment-id-clip.mov", + ); + } + }, + ); + + it("applies the remaining attachment slots to photos and videos together", async () => { + mocks.pickMedia.mockResolvedValue({ canceled: false, assets: [image, video] }); + + const result = await pickComposerMedia({ existingCount: 7, maxVideoBytes: 50 * 1024 * 1024 }); + + expect(result.attachments).toEqual([expect.objectContaining({ type: "image" })]); + expect(result.error).toBe("You can attach up to 8 attachments per message."); + expect(mocks.pickMedia).toHaveBeenCalledWith(expect.objectContaining({ selectionLimit: 1 })); + expect(mocks.copy).not.toHaveBeenCalled(); + }); + + it("reports a native video retrieval error and ends the foreground handoff", async () => { + mocks.pickMedia.mockRejectedValue(new Error("Could not download video from iCloud.")); + + await expect(pickComposerMedia({ existingCount: 0, maxVideoBytes: 1024 })).resolves.toEqual({ + attachments: [], + error: "Could not download video from iCloud.", + }); + expect(isForegroundHandoffActive()).toBe(false); + }); + }); + + it("copies picked files into app-owned storage without loading their contents", async () => { + mocks.pickFile.mockResolvedValue({ + canceled: false, + assets: [ + { + uri: "file:///downloads/report.pdf", + name: "report.pdf", + mimeType: "application/pdf", + size: 42, + }, + ], + }); + + await expect(pickComposerFiles({ existingCount: 0 })).resolves.toEqual({ + files: [ + { + id: "attachment-id", + type: "file", + name: "report.pdf", + mimeType: "application/pdf", + sizeBytes: 42, + fileUri: "file:///documents/t3-composer-attachments/attachment-id-report.pdf", + }, + ], + error: null, + }); + expect(mocks.copy).toHaveBeenCalledWith( + "file:///downloads/report.pdf", + "file:///documents/t3-composer-attachments/attachment-id-report.pdf", + ); + }); + + it("preserves Android picker metadata instead of using the content URI document id", async () => { + const uri = "content://com.android.providers.media.documents/document/video%3A18"; + mocks.pickFile.mockResolvedValue({ + canceled: false, + assets: [ + { + uri, + name: "preview-h264.mp4", + mimeType: "video/mp4", + size: 620_992, + lastModified: 0, + }, + ], + }); + mocks.size.mockReturnValue(620_992); + + await expect(pickComposerFiles({ existingCount: 0 })).resolves.toEqual({ + files: [ + { + id: "attachment-id", + type: "file", + name: "preview-h264.mp4", + mimeType: "video/mp4", + sizeBytes: 620_992, + fileUri: "file:///documents/t3-composer-attachments/attachment-id-preview-h264.mp4", + }, + ], + error: null, + }); + expect(mocks.pickFile).toHaveBeenCalledWith({ multiple: true, copyToCacheDirectory: true }); + expect(mocks.copy).toHaveBeenCalledWith( + uri, + "file:///documents/t3-composer-attachments/attachment-id-preview-h264.mp4", + ); + expect(mocks.delete).not.toHaveBeenCalled(); + }); + + it("persists provider selections that require a readable cache copy", async () => { + const providerUri = "content://cloud-provider/documents/clip"; + const cachedUri = "file:///cache/DocumentPicker/clip.mp4"; + mocks.pickFile.mockImplementation(async (options) => ({ + canceled: false, + assets: [ + { + uri: options.copyToCacheDirectory ? cachedUri : providerUri, + name: "Cloud recording.mp4", + mimeType: "video/mp4", + size: 42, + lastModified: 0, + }, + ], + })); + mocks.copy.mockImplementation((uri: string) => { + if (uri === providerUri) throw new Error("The provider URI is not directly readable."); + }); + + const result = await pickComposerFiles({ existingCount: 0 }); + + expect(result.error).toBeNull(); + expect(result.files).toEqual([ + expect.objectContaining({ + name: "Cloud recording.mp4", + fileUri: "file:///documents/t3-composer-attachments/attachment-id-Cloud recording.mp4", + }), + ]); + expect(mocks.copy).toHaveBeenCalledWith(cachedUri, result.files[0]!.fileUri); + }); + + it("ends the foreground handoff when the picker is canceled without copying files", async () => { + mocks.pickFile.mockImplementation(async () => { + expect(isForegroundHandoffActive()).toBe(true); + return { canceled: true, assets: null }; + }); + + await expect(pickComposerFiles({ existingCount: 0 })).resolves.toEqual({ + files: [], + error: null, + }); + + expect(isForegroundHandoffActive()).toBe(false); + expect(mocks.copy).not.toHaveBeenCalled(); + expect(mocks.open).not.toHaveBeenCalled(); + }); + + it("reports picker failures and releases the foreground handoff", async () => { + mocks.pickFile.mockRejectedValue(new Error("The document provider is unavailable.")); + + await expect(pickComposerFiles({ existingCount: 0 })).resolves.toEqual({ + files: [], + error: "The document provider is unavailable.", + }); + + expect(isForegroundHandoffActive()).toBe(false); + expect(mocks.copy).not.toHaveBeenCalled(); + }); + + it("does not open the picker when the draft has no remaining attachment slots", async () => { + await expect(pickComposerFiles({ existingCount: 8 })).resolves.toEqual({ + files: [], + error: "You can attach up to 8 files per message.", + }); + + expect(mocks.pickFile).not.toHaveBeenCalled(); + expect(isForegroundHandoffActive()).toBe(false); + }); + + it("falls back to a usable name when the picker reports a blank one", async () => { + mocks.pickFile.mockResolvedValue({ + canceled: false, + assets: [ + { + uri: "file:///downloads/unnamed", + name: " ", + mimeType: "application/pdf", + size: 42, + }, + ], + }); + + const result = await pickComposerFiles({ existingCount: 0 }); + expect(result.error).toBeNull(); + expect(result.files).toHaveLength(1); + expect(result.files[0]?.name).toBe("file"); + }); + + it("rejects files that exceed the environment's advertised upload limit", async () => { + mocks.pickFile.mockResolvedValue({ + canceled: false, + assets: [ + { + uri: "file:///downloads/archive.zip", + name: "archive.zip", + mimeType: "application/zip", + size: 2 * 1024 * 1024, + }, + ], + }); + + await expect(pickComposerFiles({ existingCount: 0, maxBytes: 1024 * 1024 })).resolves.toEqual({ + files: [], + error: "'archive.zip' exceeds the 1 MB attachment limit.", + }); + expect(mocks.copy).not.toHaveBeenCalled(); + }); + + it("never accepts files above the 50 MB contract limit", async () => { + mocks.pickFile.mockResolvedValue({ + canceled: false, + assets: [ + { + uri: "file:///downloads/archive.zip", + name: "archive.zip", + mimeType: "application/zip", + size: 51 * 1024 * 1024, + }, + ], + }); + + await expect( + pickComposerFiles({ existingCount: 0, maxBytes: 80 * 1024 * 1024 }), + ).resolves.toEqual({ + files: [], + error: "'archive.zip' exceeds the 50 MB attachment limit.", + }); + }); + + it("rejects a file that grew after the picker reported its size", async () => { + mocks.pickFile.mockResolvedValue({ + canceled: false, + assets: [ + { + uri: "file:///downloads/archive.zip", + name: "archive.zip", + mimeType: "application/zip", + size: 42, + }, + ], + }); + mocks.size.mockReturnValue(2 * 1024 * 1024); + + await expect(pickComposerFiles({ existingCount: 0, maxBytes: 1024 * 1024 })).resolves.toEqual({ + files: [], + error: "'archive.zip' exceeds the 1 MB attachment limit.", + }); + expect(mocks.copy).not.toHaveBeenCalled(); + }); + + it("stops copying an unknown-size content URI when it exceeds the attachment limit", async () => { + const maxBytes = 1024 * 1024; + let remainingBytes = maxBytes + 1; + const source = { + readBytes: vi.fn((length: number) => { + const size = Math.min(length, remainingBytes); + remainingBytes -= size; + return new Uint8Array(size); + }), + close: vi.fn(), + }; + const destination = { writeBytes: vi.fn(), close: vi.fn() }; + mocks.open.mockImplementation((uri: string) => + uri.startsWith("content:") ? source : destination, + ); + + await expect( + persistComposerAttachmentFile("content://shared/large", "large.bin", maxBytes), + ).rejects.toThrow("'large.bin' exceeds the 1 MB attachment limit."); + + expect(source.close).toHaveBeenCalledOnce(); + expect(destination.close).toHaveBeenCalledOnce(); + expect(mocks.delete).toHaveBeenCalledWith( + "file:///documents/t3-composer-attachments/attachment-id-large.bin", + ); + expect(mocks.copy).not.toHaveBeenCalled(); + }); + + it("rejects a copy that delivered more bytes than the source reported", async () => { + const maxBytes = 1024 * 1024; + // An Android content: stream can report a small size and still deliver + // more bytes; the persisted copy is what must satisfy the limit. + mocks.size.mockImplementation((uri: string) => + uri.startsWith("content:") ? 42 : 2 * 1024 * 1024, + ); + + await expect( + persistComposerAttachmentFile("content://shared/liar", "liar.bin", maxBytes), + ).rejects.toThrow("'liar.bin' exceeds the 1 MB attachment limit."); + + expect(mocks.copy).toHaveBeenCalledOnce(); + expect(mocks.delete).toHaveBeenCalledWith( + "file:///documents/t3-composer-attachments/attachment-id-liar.bin", + ); + }); + + it("reports an empty file without calling it oversized", async () => { + mocks.size.mockReturnValue(0); + mocks.pickFile.mockResolvedValue({ + canceled: false, + assets: [ + { + uri: "file:///downloads/empty.txt", + name: "empty.txt", + mimeType: "text/plain", + size: 0, + }, + ], + }); + + await expect(pickComposerFiles({ existingCount: 0 })).resolves.toEqual({ + files: [], + error: "'empty.txt' is empty or could not be read.", + }); + }); + + it.each([0, undefined])("copies an Android SAF file when the picker size is %s", async (size) => { + const reader = { + readBytes: vi + .fn() + .mockReturnValueOnce(new Uint8Array(42)) + .mockReturnValueOnce(new Uint8Array()), + close: vi.fn(), + }; + const writer = { writeBytes: vi.fn(), close: vi.fn() }; + mocks.size.mockImplementation((uri: string) => (uri.startsWith("content:") ? 0 : 42)); + mocks.open.mockImplementation((uri: string) => (uri.startsWith("content:") ? reader : writer)); + mocks.pickFile.mockResolvedValue({ + canceled: false, + assets: [ + { + uri: "content://shared/report", + name: "report.pdf", + mimeType: "application/pdf", + size, + }, + ], + }); + + await expect(pickComposerFiles({ existingCount: 0 })).resolves.toEqual({ + files: [ + { + id: "attachment-id", + type: "file", + name: "report.pdf", + mimeType: "application/pdf", + sizeBytes: 42, + fileUri: "file:///documents/t3-composer-attachments/attachment-id-report.pdf", + }, + ], + error: null, + }); + }); + + it("uses the remaining slot for the first valid file after an oversized selection", async () => { + mocks.pickFile.mockResolvedValue({ + canceled: false, + assets: [ + { + uri: "file:///downloads/huge.zip", + name: "huge.zip", + mimeType: "application/zip", + size: 2 * 1024 * 1024, + }, + { + uri: "file:///downloads/report.pdf", + name: "report.pdf", + mimeType: "application/pdf", + size: 42, + }, + ], + }); + + const result = await pickComposerFiles({ existingCount: 7, maxBytes: 1024 * 1024 }); + + expect(result.files.map((file) => file.name)).toEqual(["report.pdf"]); + }); + + it("removes the partial destination file when a copy fails midway", async () => { + mocks.copy.mockImplementation(() => { + throw new Error("disk full"); + }); + + await expect( + persistComposerAttachmentFile("file:///downloads/report.pdf", "report.pdf"), + ).rejects.toThrow("disk full"); + + expect(mocks.delete).toHaveBeenCalledWith( + "file:///documents/t3-composer-attachments/attachment-id-report.pdf", + ); + }); + + it("deletes app-owned attachments without touching user-owned files", async () => { + await removePersistedComposerAttachmentFile( + "file:///documents/t3-composer-attachments/report.pdf", + ); + await removePersistedComposerAttachmentFile("file:///downloads/report.pdf"); + + expect(mocks.delete).toHaveBeenCalledOnce(); + expect(mocks.delete).toHaveBeenCalledWith( + "file:///documents/t3-composer-attachments/report.pdf", + ); + }); + + it("removes a restored attachment from the current iOS document container", async () => { + const fileName = "33333333-3333-4333-8333-333333333333-report%20%23.pdf"; + const oldUri = `file:///private/var/mobile/Containers/Data/Application/11111111-1111-4111-8111-111111111111/Documents/t3-composer-attachments/${fileName}`; + mocks.documentUri = + "file:///var/mobile/Containers/Data/Application/22222222-2222-4222-8222-222222222222/Documents"; + + await removePersistedComposerAttachmentFile(oldUri); + await removePersistedComposerAttachmentFile( + `file:///var/mobile/Containers/Shared/FileProvider/other/Documents/t3-composer-attachments/${fileName}`, + ); + await removePersistedComposerAttachmentFile( + `${mocks.documentUri}/t3-composer-attachments/..%2F..%2Fsender.pdf`, + ); + + expect(mocks.delete.mock.calls).toEqual([ + [`${mocks.documentUri}/t3-composer-attachments/${fileName}`], + ]); + }); + + it("rechecks preview ownership after loading the native filesystem", async () => { + const fileName = "33333333-3333-4333-8333-333333333333-recording.mp4"; + const oldUri = `file:///private/var/mobile/Containers/Data/Application/11111111-1111-4111-8111-111111111111/Documents/t3-composer-attachments/${fileName}`; + mocks.documentUri = + "file:///var/mobile/Containers/Data/Application/22222222-2222-4222-8222-222222222222/Documents"; + const currentUri = `${mocks.documentUri}/t3-composer-attachments/${fileName}`; + + const deleting = removePersistedComposerAttachmentFile(oldUri); + const release = retainComposerAttachmentFile(currentUri, () => {}); + try { + await deleting; + expect(mocks.delete).not.toHaveBeenCalled(); + } finally { + release(); + } + + await removePersistedComposerAttachmentFile(oldUri); + expect(mocks.delete.mock.calls).toEqual([[currentUri]]); + }); + + it("copies an open-in-place source from its actual container without rebasing it", async () => { + const sourceUri = + "file:///var/mobile/Containers/Data/Application/11111111-1111-4111-8111-111111111111/Documents/t3-composer-attachments/33333333-3333-4333-8333-333333333333-report.pdf"; + mocks.documentUri = + "file:///var/mobile/Containers/Data/Application/22222222-2222-4222-8222-222222222222/Documents"; + + await persistComposerAttachmentFile(sourceUri, "report.pdf"); + + expect(mocks.copy).toHaveBeenCalledWith( + sourceUri, + `${mocks.documentUri}/t3-composer-attachments/attachment-id-report.pdf`, + ); + expect(mocks.delete).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/mobile/src/lib/composerImages.ts b/apps/mobile/src/lib/composerImages.ts index 747b7afd31bc..77c2ec225564 100644 --- a/apps/mobile/src/lib/composerImages.ts +++ b/apps/mobile/src/lib/composerImages.ts @@ -1,18 +1,45 @@ +import { + clampFileAttachmentUploadBytes, + fileAttachmentTooLargeMessage, +} from "@t3tools/client-runtime/state/attachments"; import { isProviderSendTurnSupportedImageMimeType, PROVIDER_SEND_TURN_MAX_ATTACHMENTS, + PROVIDER_SEND_TURN_MAX_FILE_BYTES, PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, + type EnvironmentId, type UploadChatImageAttachment, } from "@t3tools/contracts"; +import type { DocumentPickerResult } from "expo-document-picker"; import { estimateBase64ByteSize } from "./base64"; +import { + COMPOSER_ATTACHMENT_DIRECTORY, + isComposerAttachmentFileRetained, + resolveOwnedComposerAttachmentFileUri, +} from "./composerAttachmentFiles"; import { beginForegroundHandoff } from "./foreground-handoff"; import { uuidv4 } from "./uuid"; export interface DraftComposerImageAttachment extends UploadChatImageAttachment { readonly id: string; readonly previewUri: string; + readonly uploadedAttachmentId?: string; + readonly uploadEnvironmentId?: EnvironmentId; } +export interface DraftComposerFileAttachment { + readonly id: string; + readonly type: "file"; + readonly name: string; + readonly mimeType: string; + readonly sizeBytes: number; + readonly fileUri: string; + readonly uploadedAttachmentId?: string; + readonly uploadEnvironmentId?: EnvironmentId; +} + +export type DraftComposerAttachment = DraftComposerImageAttachment | DraftComposerFileAttachment; + /** Wire shape for startTurn: pure uploads without client draft id / previewUri. */ export function toUploadChatImageAttachments( attachments: ReadonlyArray, @@ -27,12 +54,220 @@ export function toUploadChatImageAttachments( } const OWNED_PASTED_IMAGE_DIRECTORY = "t3-composer-paste"; +const ATTACHMENT_COPY_CHUNK_BYTES = 64 * 1024; + +export async function persistComposerAttachmentFile( + uri: string, + name: string, + maxBytes?: number, +): Promise { + const { Directory, File, FileMode, Paths } = await import("expo-file-system"); + const directory = new Directory(Paths.document, COMPOSER_ATTACHMENT_DIRECTORY); + directory.create({ idempotent: true, intermediates: true }); + const safeName = + Array.from(name, (character) => + character === "/" || character === "\\" || character.charCodeAt(0) < 32 ? "-" : character, + ).join("") || "file"; + const destination = new File(directory, `${uuidv4()}-${safeName}`); + const source = new File(uri); + const sourceSize = source.size; + if ( + maxBytes !== undefined && + (sourceSize === null || (sourceSize === 0 && uri.startsWith("content:"))) + ) { + destination.create(); + try { + const reader = source.open(FileMode.ReadOnly); + try { + const writer = destination.open(FileMode.WriteOnly); + try { + let copiedBytes = 0; + while (true) { + const chunk = reader.readBytes( + Math.min(ATTACHMENT_COPY_CHUNK_BYTES, maxBytes - copiedBytes + 1), + ); + if (chunk.byteLength === 0) { + break; + } + copiedBytes += chunk.byteLength; + if (copiedBytes > maxBytes) { + throw new Error(fileAttachmentTooLargeMessage(name, maxBytes)); + } + writer.writeBytes(chunk); + } + } finally { + writer.close(); + } + } finally { + reader.close(); + } + } catch (error) { + if (destination.exists) { + destination.delete(); + } + throw error; + } + return destination.uri; + } + + if (maxBytes !== undefined && sourceSize !== null && sourceSize > maxBytes) { + throw new Error(fileAttachmentTooLargeMessage(name, maxBytes)); + } + try { + await source.copy(destination); + } catch (error) { + // A failed copy can leave a partial destination file behind with no URI + // returned to release it later; delete it before surfacing the failure. + try { + if (destination.exists) { + destination.delete(); + } + } catch (cleanupError) { + console.warn("[composer-attachments] could not remove a partial copy", cleanupError); + } + throw error; + } + // An Android content: stream can deliver more bytes than the size it + // reported before the copy. Validate the persisted copy so an oversized + // file is never retained under a stale recorded size. + const copiedSize = destination.size; + if (maxBytes !== undefined && copiedSize !== null && copiedSize > maxBytes) { + try { + if (destination.exists) { + destination.delete(); + } + } catch (cleanupError) { + console.warn("[composer-attachments] could not remove an oversized copy", cleanupError); + } + throw new Error(fileAttachmentTooLargeMessage(name, maxBytes)); + } + return destination.uri; +} + +export async function removePersistedComposerAttachmentFile(uri: string): Promise { + try { + const { File, Paths } = await import("expo-file-system"); + const ownedUri = resolveOwnedComposerAttachmentFileUri(uri, Paths.document.uri); + if (ownedUri === null || isComposerAttachmentFileRetained(ownedUri)) { + return; + } + const file = new File(ownedUri); + if (file.exists) { + file.delete(); + } + } catch (error) { + console.warn("[composer-attachments] could not remove local file", error); + } +} + +async function createComposerFileAttachment(input: { + readonly uri: string; + readonly name: string; + readonly mimeType: string; + readonly sizeBytes: number | null; + readonly maxBytes: number; +}): Promise { + if (input.sizeBytes !== null && input.sizeBytes > input.maxBytes) { + throw new Error(fileAttachmentTooLargeMessage(input.name, input.maxBytes)); + } + const { File } = await import("expo-file-system"); + const fileUri = await persistComposerAttachmentFile(input.uri, input.name, input.maxBytes); + try { + const sizeBytes = new File(fileUri).size ?? input.sizeBytes ?? 0; + if (sizeBytes <= 0) { + throw new Error(`'${input.name}' is empty or could not be read.`); + } + if (sizeBytes > input.maxBytes) { + throw new Error(fileAttachmentTooLargeMessage(input.name, input.maxBytes)); + } + return { + id: uuidv4(), + type: "file", + name: input.name, + mimeType: input.mimeType, + sizeBytes, + fileUri, + }; + } catch (error) { + await removePersistedComposerAttachmentFile(fileUri); + throw error; + } +} + +export async function pickComposerFiles(input: { + readonly existingCount: number; + readonly maxBytes?: number; +}): Promise<{ + readonly files: ReadonlyArray; + readonly error: string | null; +}> { + const remainingSlots = PROVIDER_SEND_TURN_MAX_ATTACHMENTS - input.existingCount; + if (remainingSlots <= 0) { + return { + files: [], + error: `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} files per message.`, + }; + } + + const { getDocumentAsync } = await import("expo-document-picker"); + const endHandoff = beginForegroundHandoff(); + let result: DocumentPickerResult; + try { + // File providers may expose a URI that FileSystem cannot read directly. + // Import a readable cache copy before persisting the draft's owned file. + result = await getDocumentAsync({ multiple: true, copyToCacheDirectory: true }); + } catch (cause) { + return { + files: [], + error: cause instanceof Error ? cause.message : "Could not open the file picker.", + }; + } finally { + endHandoff(); + } + if (result.canceled) { + return { files: [], error: null }; + } + + const maxBytes = clampFileAttachmentUploadBytes( + input.maxBytes ?? PROVIDER_SEND_TURN_MAX_FILE_BYTES, + ); + const attachments: DraftComposerFileAttachment[] = []; + let error: string | null = null; + let exceededAttachmentLimit = false; + for (const file of result.assets) { + if (attachments.length >= remainingSlots) { + exceededAttachmentLimit = true; + break; + } + // A SAF/document picker can hand back a blank display name; the wire + // contract rejects empty names at send time, so fall back before the name + // reaches storage, errors, or the attachment itself. + const name = file.name.trim().length > 0 ? file.name : "file"; + try { + attachments.push( + await createComposerFileAttachment({ + uri: file.uri, + name, + mimeType: file.mimeType || "application/octet-stream", + sizeBytes: file.size ?? null, + maxBytes, + }), + ); + } catch (cause) { + error = cause instanceof Error ? cause.message : `Could not read '${name}'.`; + } + } + if (exceededAttachmentLimit) { + error = `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} files per message.`; + } + return { files: attachments, error }; +} async function loadImagePicker() { try { return await import("expo-image-picker"); } catch (error) { - throw new Error("Image attachments are unavailable right now.", { cause: error }); + throw new Error("The photo library is unavailable right now.", { cause: error }); } } @@ -47,12 +282,27 @@ async function loadClipboard() { export async function pickComposerImages(input: { readonly existingCount: number }): Promise<{ readonly images: ReadonlyArray; readonly error: string | null; +}> { + const result = await pickComposerMedia(input); + return { + images: result.attachments.filter((attachment) => attachment.type === "image"), + error: result.error, + }; +} + +/** Videos use file uploads; omit maxVideoBytes for image-only destinations. */ +export async function pickComposerMedia(input: { + readonly existingCount: number; + readonly maxVideoBytes?: number; +}): Promise<{ + readonly attachments: ReadonlyArray; + readonly error: string | null; }> { const remainingSlots = PROVIDER_SEND_TURN_MAX_ATTACHMENTS - input.existingCount; if (remainingSlots <= 0) { return { - images: [], - error: `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} images per message.`, + attachments: [], + error: `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} attachments per message.`, }; } @@ -61,9 +311,8 @@ export async function pickComposerImages(input: { readonly existingCount: number imagePicker = await loadImagePicker(); } catch (error) { return { - images: [], - error: - error instanceof Error ? error.message : "Image attachments are unavailable right now.", + attachments: [], + error: error instanceof Error ? error.message : "The photo library is unavailable right now.", }; } @@ -73,62 +322,121 @@ export async function pickComposerImages(input: { readonly existingCount: number let result: Awaited>; try { result = await imagePicker.launchImageLibraryAsync({ - mediaTypes: ["images"], + mediaTypes: input.maxVideoBytes === undefined ? ["images"] : ["images", "videos"], allowsMultipleSelection: true, selectionLimit: remainingSlots, base64: true, quality: 1, + shouldDownloadFromNetwork: true, }); + } catch (error) { + return { + attachments: [], + error: error instanceof Error ? error.message : "Could not open the photo library.", + }; } finally { endHandoff(); } if (result.canceled) { return { - images: [], + attachments: [], error: null, }; } - const nextImages: DraftComposerImageAttachment[] = []; + const attachments: DraftComposerAttachment[] = []; let error: string | null = null; for (const asset of result.assets) { - const mimeType = asset.mimeType?.toLowerCase(); - if (!mimeType?.startsWith("image/")) { - error = `Unsupported file type for '${asset.fileName ?? "image"}'.`; + if (attachments.length >= remainingSlots) { + error = `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} attachments per message.`; + break; + } + let mimeType = asset.mimeType?.toLowerCase(); + if (asset.type === "video" || mimeType?.startsWith("video/")) { + if (input.maxVideoBytes === undefined) { + error = "Video attachments are unavailable here."; + continue; + } + try { + const { File } = await import("expo-file-system"); + const file = new File(asset.uri); + attachments.push( + await createComposerFileAttachment({ + uri: asset.uri, + name: asset.fileName?.trim() || file.name || "video", + mimeType: mimeType || file.type || "application/octet-stream", + sizeBytes: asset.fileSize ?? null, + maxBytes: clampFileAttachmentUploadBytes(input.maxVideoBytes), + }), + ); + } catch (cause) { + error = + cause instanceof Error ? cause.message : `Could not read '${asset.fileName ?? "video"}'.`; + } continue; } - if (!isProviderSendTurnSupportedImageMimeType(mimeType)) { - error = `'${asset.fileName ?? "image"}' is not a supported image type. Attach GIF, JPEG, PNG, or WebP images.`; + if (asset.type !== "image" && !mimeType?.startsWith("image/")) { + error = `Unsupported file type for '${asset.fileName ?? "image"}'.`; continue; } - const base64 = asset.base64; + let base64 = asset.base64; if (!base64) { error = `Failed to read '${asset.fileName ?? "image"}'.`; continue; } - const sizeBytes = asset.fileSize ?? estimateBase64ByteSize(base64); + let name = asset.fileName?.trim() || "image"; + // The iOS picker returns JPEG base64 even when its metadata describes HEIC, + // PNG, or GIF. Keep supported originals so transparency and animation survive; + // use the native JPEG conversion for formats providers cannot accept. + if (base64.startsWith("/9j/")) { + if ( + mimeType && + mimeType !== "image/jpeg" && + isProviderSendTurnSupportedImageMimeType(mimeType) + ) { + try { + const { File } = await import("expo-file-system"); + base64 = await new File(asset.uri).base64(); + } catch { + error = `Failed to read '${name}'.`; + continue; + } + } else { + mimeType = "image/jpeg"; + if (!/\.jpe?g$/i.test(name)) { + name = `${name.replace(/\.[^.]+$/, "")}.jpg`; + } + } + } + if (!mimeType || !isProviderSendTurnSupportedImageMimeType(mimeType)) { + error = `'${name}' is not a supported image type. Attach GIF, JPEG, PNG, or WebP images.`; + continue; + } + + const sizeBytes = estimateBase64ByteSize(base64); if (sizeBytes <= 0 || sizeBytes > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) { error = `'${asset.fileName ?? "image"}' exceeds the 10 MB attachment limit.`; continue; } - nextImages.push({ + const dataUrl = `data:${mimeType};base64,${base64}`; + attachments.push({ id: uuidv4(), type: "image", - name: asset.fileName ?? "image", + name, mimeType, sizeBytes, - dataUrl: `data:${mimeType};base64,${base64}`, - previewUri: asset.uri, + dataUrl, + previewUri: mimeType === asset.mimeType?.toLowerCase() ? asset.uri : dataUrl, }); } return { - images: nextImages, + attachments, error, }; } diff --git a/apps/mobile/src/lib/connection.test.ts b/apps/mobile/src/lib/connection.test.ts index 8ec0fb8bd892..6487e572c87d 100644 --- a/apps/mobile/src/lib/connection.test.ts +++ b/apps/mobile/src/lib/connection.test.ts @@ -1,12 +1,26 @@ -import { describe, expect, it, vi } from "vite-plus/test"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import { EnvironmentId } from "@t3tools/contracts"; import { isRelayManagedConnection, - authClientMetadata, redactPairingCredential, toStableSavedRemoteConnection, } from "./connection"; +import { authClientMetadata } from "./authClientMetadata"; + +const mobilePlatform = vi.hoisted(() => ({ OS: "ios" as "ios" | "android" })); +const mobileDevice = vi.hoisted(() => ({ + deviceType: 1, + DeviceType: { + UNKNOWN: 0, + PHONE: 1, + TABLET: 2, + DESKTOP: 3, + TV: 4, + }, + osVersion: "18.4.1", + modelName: "iPhone 15 Pro", +})); vi.mock("./runtime", () => ({ runtime: { @@ -15,21 +29,53 @@ vi.mock("./runtime", () => ({ })); vi.mock("react-native", () => ({ - Platform: { - OS: "ios", - }, + Platform: mobilePlatform, })); +vi.mock("expo-device", () => mobileDevice); + describe("mobile remote connection records", () => { + afterEach(() => { + mobilePlatform.OS = "ios"; + mobileDevice.deviceType = mobileDevice.DeviceType.PHONE; + mobileDevice.osVersion = "18.4.1"; + mobileDevice.modelName = "iPhone 15 Pro"; + }); + it("identifies mobile token exchanges for authorized-client presentation", () => { expect(authClientMetadata()).toEqual({ label: "T3 Code Mobile", deviceType: "mobile", os: "iOS", + osMajorVersion: 18, + deviceModel: "iPhone 15 Pro", surface: "mobile", }); }); + it("includes only the Android major version and hardware model", () => { + mobilePlatform.OS = "android"; + mobileDevice.osVersion = "15.2.1"; + mobileDevice.modelName = "Pixel 9"; + + expect(authClientMetadata()).toMatchObject({ + os: "Android", + osMajorVersion: 15, + deviceModel: "Pixel 9", + }); + }); + + it("identifies native tablets separately from phones", () => { + mobileDevice.deviceType = mobileDevice.DeviceType.TABLET; + mobileDevice.modelName = "iPad Pro 13-inch"; + + expect(authClientMetadata()).toMatchObject({ + deviceType: "tablet", + os: "iOS", + deviceModel: "iPad Pro 13-inch", + }); + }); + it("includes the mobile app version when the client provides it", () => { expect(authClientMetadata("1.2.3")).toMatchObject({ surface: "mobile", diff --git a/apps/mobile/src/lib/connection.ts b/apps/mobile/src/lib/connection.ts index 839bc70e6d95..df26a192cd0f 100644 --- a/apps/mobile/src/lib/connection.ts +++ b/apps/mobile/src/lib/connection.ts @@ -2,8 +2,6 @@ import { EnvironmentId } from "@t3tools/contracts"; import { stripPairingTokenFromUrl } from "@t3tools/shared/remote"; import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; -export { authClientMetadata } from "./authClientMetadata"; - export interface SavedRemoteConnection { readonly environmentId: EnvironmentId; readonly environmentLabel: string; diff --git a/apps/mobile/src/lib/copyTextWithHaptic.test.ts b/apps/mobile/src/lib/copyTextWithHaptic.test.ts index 236fb44cd6b0..a9e8cb049fdb 100644 --- a/apps/mobile/src/lib/copyTextWithHaptic.test.ts +++ b/apps/mobile/src/lib/copyTextWithHaptic.test.ts @@ -22,6 +22,7 @@ import { CopyTextClipboardWriteError, CopyTextHapticFeedbackError, copyTextWithHaptic, + tryCopyTextWithHaptic, } from "./copyTextWithHaptic"; describe("copyTextWithHaptic", () => { @@ -54,6 +55,19 @@ describe("copyTextWithHaptic", () => { expect(mocks.impactAsync).not.toHaveBeenCalled(); }); + it("reports whether the clipboard write succeeded", async () => { + mocks.setStringAsync.mockResolvedValueOnce(undefined); + + await expect(tryCopyTextWithHaptic("thread-123")).resolves.toBe(true); + }); + + it("returns false when the clipboard write fails", async () => { + vi.spyOn(console, "error").mockImplementation(() => undefined); + mocks.setStringAsync.mockRejectedValueOnce(new Error("native clipboard failure")); + + await expect(tryCopyTextWithHaptic("thread-123")).resolves.toBe(false); + }); + it("reports structured failures without including clipboard contents", async () => { const clipboardCause = new Error("native clipboard failure"); const hapticCause = new Error("native haptic failure"); diff --git a/apps/mobile/src/lib/copyTextWithHaptic.ts b/apps/mobile/src/lib/copyTextWithHaptic.ts index 1cc8c94eef7a..3a7da03b5ea3 100644 --- a/apps/mobile/src/lib/copyTextWithHaptic.ts +++ b/apps/mobile/src/lib/copyTextWithHaptic.ts @@ -27,19 +27,22 @@ export class CopyTextHapticFeedbackError extends Schema.TaggedErrorClass { const target = options.target ?? "text"; const feedback = options.feedback ?? "light-impact"; - void (async () => { + const clipboardWrite = (async () => { try { await Clipboard.setStringAsync(value); + return true; } catch (cause) { console.error( new CopyTextClipboardWriteError({ @@ -47,6 +50,7 @@ export function copyTextWithHaptic( cause, }), ); + return false; } })(); @@ -67,4 +71,10 @@ export function copyTextWithHaptic( ); } })(); + + return await clipboardWrite; +} + +export function copyTextWithHaptic(value: string, options: CopyTextWithHapticOptions = {}): void { + void tryCopyTextWithHaptic(value, options); } diff --git a/apps/mobile/src/lib/filePreview.test.ts b/apps/mobile/src/lib/filePreview.test.ts new file mode 100644 index 000000000000..50be5369c7d3 --- /dev/null +++ b/apps/mobile/src/lib/filePreview.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { isPdfFile } from "./filePreview"; + +describe("PDF preview detection", () => { + it.each([ + [{ name: "download", mimeType: "application/pdf" }, true], + [{ name: "download", mimeType: "APPLICATION/PDF; charset=binary" }, true], + [{ name: "Report.PDF", mimeType: "application/octet-stream" }, true], + [{ name: "https://example.com/report.pdf?signature=abc#page=2" }, true], + [{ name: "report.pdf", mimeType: "text/plain" }, false], + [{ name: "report.pdf.exe" }, false], + [{ name: "https://example.com/page?download=report.pdf" }, false], + ])("classifies %j as %s", (file, expected) => { + expect(isPdfFile(file)).toBe(expected); + }); +}); diff --git a/apps/mobile/src/lib/filePreview.ts b/apps/mobile/src/lib/filePreview.ts new file mode 100644 index 000000000000..7ee96476d720 --- /dev/null +++ b/apps/mobile/src/lib/filePreview.ts @@ -0,0 +1,6 @@ +/** MIME metadata wins; use the extension for files reported without a specific type. */ +export function isPdfFile(file: { readonly name: string; readonly mimeType?: string }): boolean { + const mimeType = file.mimeType?.split(";", 1)[0]?.trim().toLowerCase(); + if (mimeType && mimeType !== "application/octet-stream") return mimeType === "application/pdf"; + return /\.pdf$/i.test(file.name.split(/[?#]/, 1)[0] ?? ""); +} diff --git a/apps/mobile/src/lib/foundation-fast-refresh.ts b/apps/mobile/src/lib/foundation-fast-refresh.ts new file mode 100644 index 000000000000..70011fbcea52 --- /dev/null +++ b/apps/mobile/src/lib/foundation-fast-refresh.ts @@ -0,0 +1,21 @@ +export interface FoundationHotModule { + readonly accept: (callback?: () => void) => void; + readonly dispose: (callback: () => void) => void; +} + +export function disposeOnFoundationReplace( + hotModule: FoundationHotModule | undefined, + dispose: () => void | Promise, +): void { + if (hotModule === undefined || typeof __DEV__ === "undefined" || !__DEV__) return; + + hotModule.dispose(() => { + try { + void Promise.resolve(dispose()).catch((error: unknown) => { + console.error("[fast-refresh] could not dispose replaced mobile foundation", error); + }); + } catch (error) { + console.error("[fast-refresh] could not dispose replaced mobile foundation", error); + } + }); +} diff --git a/apps/mobile/src/lib/hot-swappable-atom-runtime.test.ts b/apps/mobile/src/lib/hot-swappable-atom-runtime.test.ts new file mode 100644 index 000000000000..b4d596900a41 --- /dev/null +++ b/apps/mobile/src/lib/hot-swappable-atom-runtime.test.ts @@ -0,0 +1,197 @@ +import { describe, expect, it, vi } from "vite-plus/test"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; + +import { hotSwappableAtomRuntime } from "./hot-swappable-atom-runtime"; + +class RuntimeValue extends Context.Service()( + "t3/mobile/test/RuntimeValue", +) {} + +function runtimeLayer(value: string, events: string[]) { + return Layer.effect( + RuntimeValue, + Effect.acquireRelease( + Effect.sync(() => { + events.push(`acquire:${value}`); + return RuntimeValue.of({ value }); + }), + () => + Effect.sync(() => { + events.push(`release:${value}`); + }), + ), + ); +} + +function runtimeLayerWithRelease( + value: string, + events: string[], + release: Effect.Effect = Effect.sync(() => { + events.push(`release:${value}`); + }), +) { + return Layer.effect( + RuntimeValue, + Effect.acquireRelease( + Effect.sync(() => { + events.push(`acquire:${value}`); + return RuntimeValue.of({ value }); + }), + () => release, + ), + ); +} + +describe("hotSwappableAtomRuntime", () => { + it("rebuilds a mounted runtime in place without disturbing unrelated subscribers", () => { + vi.stubGlobal("__DEV__", true); + const registry = AtomRegistry.make(); + const accept = () => {}; + const events: string[] = []; + const id = `test-${crypto.randomUUID()}`; + const runtime = hotSwappableAtomRuntime({ + id, + hotModule: { accept }, + registry, + layer: runtimeLayer("first", events), + }); + const valueAtom = runtime.atom(RuntimeValue.pipe(Effect.map((service) => service.value))); + const values: string[] = []; + const unsubscribeRuntime = registry.subscribe( + valueAtom, + (result) => { + if (AsyncResult.isSuccess(result)) values.push(result.value); + }, + { immediate: true }, + ); + const unrelatedAtom = Atom.make(0); + const unrelatedValues: number[] = []; + const unsubscribeUnrelated = registry.subscribe(unrelatedAtom, (value) => { + unrelatedValues.push(value); + }); + registry.set(unrelatedAtom, 7); + const unwatchedDraftAtom = Atom.make("saved"); + registry.set(unwatchedDraftAtom, "edited"); + const nodesBefore = registry.getNodes().size; + + const replacement = hotSwappableAtomRuntime({ + id, + hotModule: { accept }, + registry, + layer: runtimeLayer("second", events), + }); + hotSwappableAtomRuntime({ + id, + hotModule: { accept }, + registry, + layer: runtimeLayer("third", events), + }); + registry.set(unrelatedAtom, 8); + + expect(replacement).toBe(runtime); + expect(events).toEqual([ + "acquire:first", + "release:first", + "acquire:second", + "release:second", + "acquire:third", + ]); + expect(values).toEqual(["first", "second", "third"]); + expect(registry.get(unwatchedDraftAtom)).toBe("edited"); + expect(unrelatedValues).toEqual([7, 8]); + expect(registry.getNodes().size).toBe(nodesBefore); + unsubscribeRuntime(); + unsubscribeUnrelated(); + registry.dispose(); + expect(events).toEqual([ + "acquire:first", + "release:first", + "acquire:second", + "release:second", + "acquire:third", + "release:third", + ]); + }); + + it("does not retain or accept a runtime outside development", () => { + vi.stubGlobal("__DEV__", false); + const registry = AtomRegistry.make(); + const accept = vi.fn(); + const layer = runtimeLayer("production", []); + + const first = hotSwappableAtomRuntime({ + id: "production", + hotModule: { accept }, + registry, + layer, + }); + const second = hotSwappableAtomRuntime({ + id: "production", + hotModule: { accept }, + registry, + layer, + }); + + expect(second).not.toBe(first); + expect(accept).not.toHaveBeenCalled(); + registry.dispose(); + }); + + it("starts an asynchronous old-layer release while exposing the fresh context", async () => { + vi.stubGlobal("__DEV__", true); + const registry = AtomRegistry.make(); + const events: string[] = []; + const id = `test-${crypto.randomUUID()}`; + let finishRelease!: () => void; + const releaseGate = new Promise((resolve) => { + finishRelease = resolve; + }); + let markReleaseComplete!: () => void; + const releaseComplete = new Promise((resolve) => { + markReleaseComplete = resolve; + }); + const firstRelease = Effect.promise(async () => { + events.push("release:start:first"); + await releaseGate; + events.push("release:end:first"); + markReleaseComplete(); + }); + const runtime = hotSwappableAtomRuntime({ + id, + hotModule: { accept() {} }, + registry, + layer: runtimeLayerWithRelease("first", events, firstRelease), + }); + const valueAtom = runtime.atom(RuntimeValue.pipe(Effect.map((service) => service.value))); + const values: string[] = []; + const unsubscribe = registry.subscribe(valueAtom, (result) => { + if (AsyncResult.isSuccess(result)) values.push(result.value); + }); + registry.get(valueAtom); + + hotSwappableAtomRuntime({ + id, + hotModule: { accept() {} }, + registry, + layer: runtimeLayer("second", events), + }); + + expect(events).toEqual(["acquire:first", "release:start:first", "acquire:second"]); + expect(values).toEqual(["first", "second"]); + + finishRelease(); + await releaseComplete; + expect(events).toEqual([ + "acquire:first", + "release:start:first", + "acquire:second", + "release:end:first", + ]); + + unsubscribe(); + registry.dispose(); + }); +}); diff --git a/apps/mobile/src/lib/hot-swappable-atom-runtime.ts b/apps/mobile/src/lib/hot-swappable-atom-runtime.ts new file mode 100644 index 000000000000..0a0587a775f4 --- /dev/null +++ b/apps/mobile/src/lib/hot-swappable-atom-runtime.ts @@ -0,0 +1,63 @@ +import * as Layer from "effect/Layer"; +import { Atom, AtomRegistry, Reactivity } from "effect/unstable/reactivity"; + +export interface AcceptingHotModule { + readonly accept: (callback?: () => void) => void; +} + +interface HotAtomRuntimeEntry { + readonly layerAtom: Atom.Writable< + Layer.Layer + >; + readonly runtime: Atom.AtomRuntime; +} + +const hotAtomRuntimesKey = Symbol.for("t3.mobile.hot-atom-runtimes"); + +type HotAtomRuntimeGlobal = typeof globalThis & { + [hotAtomRuntimesKey]?: Map; +}; + +function hotAtomRuntimes(): Map { + const runtimeGlobal = globalThis as HotAtomRuntimeGlobal; + return (runtimeGlobal[hotAtomRuntimesKey] ??= new Map()); +} + +export function hotSwappableAtomRuntime(options: { + readonly id: string; + readonly hotModule: AcceptingHotModule | undefined; + readonly registry: AtomRegistry.AtomRegistry; + readonly layer: Layer.Layer; +}): Atom.AtomRuntime { + if (options.hotModule === undefined || typeof __DEV__ === "undefined" || !__DEV__) { + return Atom.runtime(options.layer); + } + + const runtimes = hotAtomRuntimes(); + const existing = runtimes.get(options.id); + let entry: HotAtomRuntimeEntry; + + if (existing === undefined) { + const layerAtom = Atom.make(options.layer); + entry = { + layerAtom: layerAtom as HotAtomRuntimeEntry["layerAtom"], + runtime: Atom.runtime((get) => get(layerAtom)) as HotAtomRuntimeEntry["runtime"], + }; + runtimes.set(options.id, entry); + } else { + entry = existing; + options.registry.set( + entry.layerAtom, + options.layer as Layer.Layer< + unknown, + unknown, + AtomRegistry.AtomRegistry | Reactivity.Reactivity + >, + ); + } + + // This is a real HMR boundary: importers retain the stable AtomRuntime while + // this module evaluation installs the freshly constructed Layer above. + options.hotModule.accept(); + return entry.runtime as Atom.AtomRuntime; +} diff --git a/apps/mobile/src/lib/localAttachmentPreview.test.ts b/apps/mobile/src/lib/localAttachmentPreview.test.ts new file mode 100644 index 000000000000..2ed83b26f640 --- /dev/null +++ b/apps/mobile/src/lib/localAttachmentPreview.test.ts @@ -0,0 +1,127 @@ +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const mocks = vi.hoisted(() => ({ + retain: vi.fn(), + share: vi.fn(), + exists: vi.fn(), +})); + +vi.mock("../state/use-composer-drafts", () => ({ + retainComposerAttachmentFileForPreview: mocks.retain, +})); +vi.mock("./attachmentDownload", () => ({ shareLocalAttachment: mocks.share })); +vi.mock("expo-file-system", () => ({ + File: class { + constructor(readonly uri: string) {} + get exists(): boolean { + return mocks.exists(this.uri); + } + }, + Paths: { + document: { + uri: "file:///var/mobile/Containers/Data/Application/22222222-2222-4222-8222-222222222222/Documents/", + }, + }, +})); + +import { loadLocalAttachmentPreview } from "./localAttachmentPreview"; + +const attachment = { + type: "file" as const, + id: "draft-video", + name: "clip.mov", + mimeType: "video/quicktime", + sizeBytes: 12, + fileUri: + "file:///var/mobile/Containers/Data/Application/11111111-1111-4111-8111-111111111111/Documents/t3-composer-attachments/33333333-3333-4333-8333-333333333333-clip.mov", +}; + +beforeEach(() => { + mocks.retain.mockReset(); + mocks.share.mockReset(); + mocks.exists.mockReset(); + mocks.retain.mockImplementation(() => vi.fn()); + mocks.exists.mockReturnValue(true); + mocks.share.mockResolvedValue(undefined); +}); + +describe("loadLocalAttachmentPreview", () => { + it("retains and shares a PDF with its original filename and type", async () => { + const pdf = { ...attachment, name: "report.pdf", mimeType: "application/pdf" }; + const preview = await loadLocalAttachmentPreview(pdf, new AbortController().signal); + await preview!.share(new AbortController().signal); + expect(mocks.share).toHaveBeenCalledWith( + expect.objectContaining({ + attachment: { name: "report.pdf", mimeType: "application/pdf" }, + }), + ); + expect(mocks.retain.mock.results[0]!.value).not.toHaveBeenCalled(); + preview!.dispose(); + expect(mocks.retain.mock.results[0]!.value).toHaveBeenCalledTimes(1); + }); + it("resolves the current iOS container and releases its playback lease once", async () => { + const preview = await loadLocalAttachmentPreview(attachment, new AbortController().signal); + expect(preview?.uri).toContain("/22222222-2222-4222-8222-222222222222/Documents/"); + expect(mocks.retain).toHaveBeenCalledWith(attachment); + const release = mocks.retain.mock.results[0]!.value; + expect(release).not.toHaveBeenCalled(); + preview?.dispose(); + preview?.dispose(); + expect(release).toHaveBeenCalledTimes(1); + }); + + it.each([undefined, "share-button"])( + "keeps a separate share lease after playback closes (source: %s)", + async (sourceIdentifier) => { + const shared = Promise.withResolvers(); + mocks.share.mockReturnValue(shared.promise); + const preview = await loadLocalAttachmentPreview(attachment, new AbortController().signal); + const share = preview!.share(new AbortController().signal, sourceIdentifier); + expect(mocks.retain).toHaveBeenCalledTimes(2); + const releasePlayback = mocks.retain.mock.results[0]!.value; + const releaseShare = mocks.retain.mock.results[1]!.value; + preview!.dispose(); + expect(releasePlayback).toHaveBeenCalledTimes(1); + expect(releaseShare).not.toHaveBeenCalled(); + shared.resolve(); + await share; + expect(releaseShare).toHaveBeenCalledTimes(1); + }, + ); + + it("releases a failed share while keeping playback retained", async () => { + mocks.share.mockRejectedValue(new Error("Sharing unavailable")); + const preview = await loadLocalAttachmentPreview(attachment, new AbortController().signal); + await expect(preview!.share(new AbortController().signal)).rejects.toThrow( + "Sharing unavailable", + ); + expect(mocks.retain.mock.results[1]!.value).toHaveBeenCalledTimes(1); + expect(mocks.retain.mock.results[0]!.value).not.toHaveBeenCalled(); + preview!.dispose(); + }); + + it("releases a load canceled during native module loading", async () => { + const controller = new AbortController(); + const loading = loadLocalAttachmentPreview(attachment, controller.signal); + controller.abort(); + await expect(loading).resolves.toBeNull(); + expect(mocks.retain.mock.results[0]!.value).toHaveBeenCalledTimes(1); + expect(mocks.exists).not.toHaveBeenCalled(); + }); + + it("reports missing files and releases their lease", async () => { + mocks.exists.mockReturnValue(false); + await expect( + loadLocalAttachmentPreview(attachment, new AbortController().signal), + ).rejects.toThrow("This attachment is no longer available. Attach the file again."); + expect(mocks.retain.mock.results[0]!.value).toHaveBeenCalledTimes(1); + }); + + it("does not start sharing a disposed preview", async () => { + const preview = await loadLocalAttachmentPreview(attachment, new AbortController().signal); + preview!.dispose(); + await preview!.share(new AbortController().signal); + expect(mocks.share).not.toHaveBeenCalled(); + expect(mocks.retain).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/mobile/src/lib/localAttachmentPreview.ts b/apps/mobile/src/lib/localAttachmentPreview.ts new file mode 100644 index 000000000000..bdd20e2e63d5 --- /dev/null +++ b/apps/mobile/src/lib/localAttachmentPreview.ts @@ -0,0 +1,59 @@ +import { videoMimeType } from "@t3tools/shared/video"; + +import type { DraftComposerFileAttachment } from "./composerImages"; +import { resolveOwnedComposerAttachmentFileUri } from "./composerAttachmentFiles"; +import { shareLocalAttachment, type AttachmentPreviewFile } from "./attachmentDownload"; +import { retainComposerAttachmentFileForPreview } from "../state/use-composer-drafts"; + +/** Retains the draft original for preview and gives each outgoing share its own lease. */ +export async function loadLocalAttachmentPreview( + attachment: DraftComposerFileAttachment, + signal: AbortSignal, +): Promise { + if (signal.aborted) return null; + const release = retainComposerAttachmentFileForPreview(attachment); + try { + const { File, Paths } = await import("expo-file-system"); + if (signal.aborted) { + release(); + return null; + } + const uri = + resolveOwnedComposerAttachmentFileUri(attachment.fileUri, Paths.document.uri) ?? + attachment.fileUri; + const file = new File(uri); + if (!file.exists) { + throw new Error("The local attachment file is missing."); + } + let disposed = false; + return { + uri: file.uri, + dispose: () => { + if (disposed) return; + disposed = true; + release(); + }, + share: async (shareSignal, sourceIdentifier) => { + if (disposed || shareSignal.aborted) return; + const releaseShare = retainComposerAttachmentFileForPreview(attachment); + try { + await shareLocalAttachment({ + uri: file.uri, + attachment: { + name: attachment.name, + mimeType: videoMimeType(attachment) ?? attachment.mimeType, + }, + signal: shareSignal, + sourceIdentifier, + }); + } finally { + releaseShare(); + } + }, + }; + } catch (cause) { + release(); + if (signal.aborted) return null; + throw new Error("This attachment is no longer available. Attach the file again.", { cause }); + } +} diff --git a/apps/mobile/src/lib/markdownLinks.test.ts b/apps/mobile/src/lib/markdownLinks.test.ts index ff57287b7412..bf3d009b74ab 100644 --- a/apps/mobile/src/lib/markdownLinks.test.ts +++ b/apps/mobile/src/lib/markdownLinks.test.ts @@ -3,6 +3,22 @@ import { describe, expect, it } from "vite-plus/test"; import { resolveMarkdownLinkPresentation } from "@t3tools/mobile-markdown-text/links"; describe("resolveMarkdownLinkPresentation", () => { + it("treats protocol-relative media as an external URL, not a filesystem path", () => { + expect(resolveMarkdownLinkPresentation("//cdn.example.com/clip.mp4?sig=a%2fb#t=2")).toEqual({ + kind: "external", + href: "https://cdn.example.com/clip.mp4?sig=a%2fb#t=2", + host: "cdn.example.com", + }); + }); + + it("separates encoded filename characters from a video playback fragment", () => { + expect(resolveMarkdownLinkPresentation("/tmp/clip%23one.mp4#t=2")).toMatchObject({ + path: "/tmp/clip#one.mp4", + label: "clip#one.mp4", + icon: "video", + }); + }); + it("extracts external link hosts", () => { expect(resolveMarkdownLinkPresentation("https://example.com/docs?q=1")).toEqual({ kind: "external", @@ -11,15 +27,16 @@ describe("resolveMarkdownLinkPresentation", () => { }); }); - it("renders file URLs as basename pills with positions", () => { - expect( - resolveMarkdownLinkPresentation("file:///Users/julius/project/src/main.ts#L42C7"), - ).toEqual({ + it.each([ + ["file:///Users/julius/project/src/main.ts#L42C7", "/Users/julius/project/src/main.ts"], + ["file://server/share/src/main.ts#L42C7", "\\\\server\\share\\src\\main.ts"], + ])("preserves the file URL path and position for %s", (href, path) => { + expect(resolveMarkdownLinkPresentation(href)).toEqual({ kind: "file", - href: "file:///Users/julius/project/src/main.ts#L42C7", + href, icon: "typescript", label: "main.ts:42:7", - path: "/Users/julius/project/src/main.ts", + path, line: 42, column: 7, }); @@ -50,6 +67,24 @@ describe("resolveMarkdownLinkPresentation", () => { }); }); + it.each(["md", "html", "xml"])("recognizes a bare spaced .%s filename", (extension) => { + expect( + resolveMarkdownLinkPresentation(`Updated%20cutover%20checklist.${extension}`), + ).toMatchObject({ + kind: "file", + path: `Updated cutover checklist.${extension}`, + label: `Updated cutover checklist.${extension}`, + }); + }); + + it("recognizes spaced relative paths", () => { + expect(resolveMarkdownLinkPresentation("docs/My%20Folder/checklist.xml")).toMatchObject({ + kind: "file", + path: "docs/My Folder/checklist.xml", + label: "checklist.xml", + }); + }); + it("extracts line fragments from relative file links", () => { expect(resolveMarkdownLinkPresentation("src/main.ts#L18C2")).toMatchObject({ kind: "file", diff --git a/apps/mobile/src/lib/markdownMedia.test.ts b/apps/mobile/src/lib/markdownMedia.test.ts new file mode 100644 index 000000000000..77834630dc2f --- /dev/null +++ b/apps/mobile/src/lib/markdownMedia.test.ts @@ -0,0 +1,77 @@ +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { resolveMarkdownMediaPreview } from "./markdownMedia"; + +const input = { + environmentId: EnvironmentId.make("environment-1"), + threadId: ThreadId.make("thread-1"), + workspaceRoot: "/repo", +}; + +describe("resolveMarkdownMediaPreview", () => { + it("decodes remote filenames once without changing the authored URL", () => { + const href = "https://cdn.example.com/clip%20one%2520%2Emp4?signature=a%2fb#t=2"; + expect(resolveMarkdownMediaPreview(href, input)).toMatchObject({ + kind: "video", + source: { + uri: href, + actionsSource: { + name: "clip one%20.mp4", + mimeType: "video/mp4", + reference: { kind: "url", url: href }, + }, + }, + }); + }); + + it("provides extensionless image actions only for image embeds", () => { + const href = "https://cdn.example.com/render?id=42"; + expect(resolveMarkdownMediaPreview(href, input)).toBeNull(); + expect(resolveMarkdownMediaPreview(href, { ...input, imageEmbed: true })).toMatchObject({ + kind: "image", + source: { actionsSource: { reference: { kind: "url", url: href }, mimeType: "image/*" } }, + }); + }); + + it.each([ + ["/tmp/frame%23one.png:12", "/tmp/frame#one.png"], + ["/tmp/frame%3Fone.png:12:3", "/tmp/frame?one.png"], + ["/tmp/frame%2523one.png:12", "/tmp/frame%23one.png"], + ["file://server/share/frame.png", "\\\\server\\share\\frame.png"], + ["\\\\server\\share\\frame.png", "\\\\server\\share\\frame.png"], + ])("keeps encoded filename and UNC semantics for %s", (href, path) => { + expect(resolveMarkdownMediaPreview(href, input)).toMatchObject({ + kind: "image", + source: { + resource: { path }, + actionsSource: { reference: { kind: "file", path } }, + }, + }); + }); + + it("separates a video playback fragment from literal filename characters", () => { + expect(resolveMarkdownMediaPreview("/tmp/clip%23one.mp4#t=2", input)).toMatchObject({ + kind: "video", + source: { + srcFragment: "#t=2", + resource: { path: "/tmp/clip#one.mp4" }, + actionsSource: { reference: { kind: "file", path: "/tmp/clip#one.mp4" } }, + }, + }); + }); + + it("resolves protocol-relative media for native APIs without rewriting its signed query", () => { + expect( + resolveMarkdownMediaPreview("//cdn.example.com/clip.mp4?signature=a%2fb#t=2", input), + ).toMatchObject({ + kind: "video", + source: { + uri: "https://cdn.example.com/clip.mp4?signature=a%2fb#t=2", + actionsSource: { + reference: { kind: "url", url: "//cdn.example.com/clip.mp4?signature=a%2fb#t=2" }, + }, + }, + }); + }); +}); diff --git a/apps/mobile/src/lib/markdownMedia.ts b/apps/mobile/src/lib/markdownMedia.ts new file mode 100644 index 000000000000..2196c2f22f2b --- /dev/null +++ b/apps/mobile/src/lib/markdownMedia.ts @@ -0,0 +1,89 @@ +import { + classifyMarkdownImageSource, + markdownImageSourceFragment, +} from "@t3tools/client-runtime/markdown-images"; +import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { normalizeNativeMarkdownUrl } from "@t3tools/mobile-markdown-text/links"; +import { mediaMimeType, mediaMimeTypeFromExtension } from "@t3tools/shared/filePreview"; +import { + mediaFileReference, + mediaReferenceFileName, + mediaUrlReference, +} from "@t3tools/client-runtime/media-reference"; + +import type { FilePreviewSource } from "../components/FilePreviewModal"; +import type { MediaVideoPreviewSource } from "./videoPreviewSource"; +import type { MediaActionsSource } from "./mediaActions"; + +/** Resolves only explicit media references. Ordinary links keep their existing navigation. */ +export function resolveMarkdownMediaPreview( + href: string, + input: { + readonly environmentId: EnvironmentId; + readonly threadId: ThreadId; + readonly workspaceRoot: string | null | undefined; + /** Image syntax can target an endpoint without a recognizable extension. */ + readonly imageEmbed?: boolean; + }, +): + | { readonly kind: "image"; readonly source: FilePreviewSource } + | { readonly kind: "video"; readonly source: MediaVideoPreviewSource } + | null { + const classified = classifyMarkdownImageSource(href, input.workspaceRoot); + if (classified._tag === "Blocked") return null; + const path = + classified._tag === "WorkspaceFile" + ? classified.path.replace(/:\d+(?::\d+)?$/, "") + : classified.uri.split(/[?#]/, 1)[0]!; + const basename = path.split(/[\\/]/).at(-1) ?? ""; + const extensionIndex = basename.lastIndexOf("."); + // Local paths have already been decoded. Do not interpret literal #, ?, or % characters again. + const detectedMimeType = + classified._tag === "Direct" + ? mediaMimeType(classified.uri) + : extensionIndex < 0 + ? null + : mediaMimeTypeFromExtension(basename.slice(extensionIndex)); + const mimeType = detectedMimeType ?? (input.imageEmbed ? "image/*" : null); + if (mimeType === null) return null; + const kind = mimeType.startsWith("video/") ? "video" : "image"; + const reference = + classified._tag === "Direct" + ? mediaUrlReference(classified.uri) + : mediaFileReference(path, input.workspaceRoot); + const name = + (reference && mediaReferenceFileName(reference)) || (kind === "video" ? "Video" : "Image"); + const srcFragment = markdownImageSourceFragment(href); + const target = + classified._tag === "Direct" + ? { uri: normalizeNativeMarkdownUrl(classified.uri) } + : { + environmentId: input.environmentId, + resource: { + _tag: "media-file" as const, + threadId: input.threadId, + path, + }, + ...(srcFragment ? { srcFragment } : {}), + }; + const actionsSource: MediaActionsSource = + classified._tag === "Direct" + ? { reference, uri: classified.uri, name, mimeType } + : { + reference, + environmentId: input.environmentId, + threadId: input.threadId, + resource: { _tag: "media-file", threadId: input.threadId, path }, + name, + mimeType, + }; + return kind === "video" + ? { + kind, + source: { type: "media", name, mimeType, ...target, actionsSource }, + } + : { + kind, + source: { kind, name, ...target, actionsSource }, + }; +} diff --git a/apps/mobile/src/lib/mediaActions.ts b/apps/mobile/src/lib/mediaActions.ts new file mode 100644 index 000000000000..14dc2de7bd21 --- /dev/null +++ b/apps/mobile/src/lib/mediaActions.ts @@ -0,0 +1,121 @@ +import { useNavigation } from "@react-navigation/native"; +import type { MediaReference } from "@t3tools/client-runtime/media-reference"; +import type { AssetResource, EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { normalizeNativeMarkdownUrl } from "@t3tools/mobile-markdown-text/links"; +import { useEffect, useRef, useState } from "react"; +import { Alert } from "react-native"; + +import { useRefreshAssetUrl } from "../state/assets"; +import { downloadAndShareAttachment, shareLocalAttachment } from "./attachmentDownload"; +import { copyTextWithHaptic } from "./copyTextWithHaptic"; + +/** Authored source metadata is kept separate from temporary preview/download URLs. */ +export type MediaActionsSource = { + readonly reference?: MediaReference; + readonly name: string; + readonly mimeType: string; +} & ( + | { readonly uri: string } + | { + readonly environmentId: EnvironmentId; + readonly threadId: ThreadId; + readonly resource: AssetResource; + } +); + +export function useMediaActions(source: MediaActionsSource | undefined, onOpenFile?: () => void) { + const navigation = useNavigation(); + const refresh = useRefreshAssetUrl( + source && "environmentId" in source ? source.environmentId : null, + source && "resource" in source ? source.resource : null, + ); + const controller = useRef(null); + const [sharing, setSharing] = useState(false); + useEffect(() => () => controller.current?.abort(), []); + + const share = () => { + if (!source || controller.current) return; + const request = new AbortController(); + controller.current = request; + setSharing(true); + void (async () => { + const uri = "uri" in source ? normalizeNativeMarkdownUrl(source.uri) : await refresh(); + if (request.signal.aborted) return; + if (uri === null) throw new Error("The file could not be loaded. Reconnect and try again."); + const input = { + attachment: { name: source.name, mimeType: source.mimeType }, + signal: request.signal, + }; + if (/^(file|content):/i.test(uri)) await shareLocalAttachment({ ...input, uri }); + else await downloadAndShareAttachment({ ...input, url: uri }); + })() + .catch((error: unknown) => { + if (!request.signal.aborted) { + Alert.alert( + "Could not share file", + error instanceof Error ? error.message : "Try again.", + ); + } + }) + .finally(() => { + if (controller.current === request) { + controller.current = null; + if (!request.signal.aborted) setSharing(false); + } + }); + }; + + const reference = source?.reference; + const actions: { id: string; title: string; run: () => void; disabled?: boolean }[] = source + ? [ + ...(reference?.kind === "file" + ? [ + { + id: "copy-path", + title: "Copy full path", + run: () => copyTextWithHaptic(reference.path), + }, + ...(reference.relativePath + ? [ + { + id: "copy-relative-path", + title: "Copy relative path", + run: () => copyTextWithHaptic(reference.relativePath!), + }, + ] + : []), + ...(reference.relativePath && source && "environmentId" in source + ? [ + { + id: "open-file", + title: "Open in file viewer", + run: () => { + onOpenFile?.(); + navigation.navigate("ThreadFile", { + environmentId: String(source.environmentId), + threadId: String(source.threadId), + path: reference.relativePath!.split("/"), + }); + }, + }, + ] + : []), + ] + : reference + ? [{ id: "copy-url", title: "Copy URL", run: () => copyTextWithHaptic(reference.url) }] + : []), + { + id: "share", + title: sharing ? "Opening share sheet…" : "Save or share", + run: share, + disabled: sharing, + }, + ] + : []; + return { + title: reference?.kind === "file" ? reference.path : reference?.url, + actions, + sharing, + share, + }; +} diff --git a/apps/mobile/src/lib/menu-action-colors.test.ts b/apps/mobile/src/lib/menu-action-colors.test.ts new file mode 100644 index 000000000000..a6ee03e58c42 --- /dev/null +++ b/apps/mobile/src/lib/menu-action-colors.test.ts @@ -0,0 +1,67 @@ +import type { MenuAction } from "@react-native-menu/menu"; +import { describe, expect, it } from "vite-plus/test"; + +import { withMenuActionIconColors } from "./menu-action-colors"; + +describe("withMenuActionIconColors", () => { + it.each(["#111111", "#eeeeee"])( + "gives icons a visible color at every menu depth for the %s theme", + (icon) => { + const actions: MenuAction[] = [ + { id: "photos", title: "Photos", image: "photo" }, + { + title: "Thread", + subactions: [ + { + title: "Pinned thread", + image: "pin", + subactions: [{ title: "Move up", image: "arrow.up" }], + }, + ], + }, + ]; + + const result = withMenuActionIconColors(actions, { icon, destructiveIcon: "#ff0000" }); + + expect(result[0]?.imageColor).toBe(icon); + expect(result[1]).not.toHaveProperty("imageColor"); + expect(result[1]?.subactions?.[0]?.imageColor).toBe(icon); + expect(result[1]?.subactions?.[0]?.subactions?.[0]?.imageColor).toBe(icon); + expect(actions[0]).not.toHaveProperty("imageColor"); + expect(actions[1]?.subactions?.[0]).not.toHaveProperty("imageColor"); + }, + ); + + it("uses the destructive color while retaining action state and attributes", () => { + const action: MenuAction = { + id: "delete", + title: "Delete", + image: "trash", + state: "off", + attributes: { destructive: true, disabled: true }, + }; + + expect( + withMenuActionIconColors([action], { + icon: "#111111", + destructiveIcon: "#cc0000", + }), + ).toEqual([{ ...action, imageColor: "#cc0000" }]); + }); + + it.each(["#123456", "transparent", 0])("preserves explicit icon color %s", (imageColor) => { + const action: MenuAction = { + title: "Delete", + image: "trash", + imageColor, + attributes: { destructive: true }, + }; + + expect( + withMenuActionIconColors([action], { + icon: "#111111", + destructiveIcon: "#cc0000", + }), + ).toEqual([action]); + }); +}); diff --git a/apps/mobile/src/lib/menu-action-colors.ts b/apps/mobile/src/lib/menu-action-colors.ts new file mode 100644 index 000000000000..611784319ca5 --- /dev/null +++ b/apps/mobile/src/lib/menu-action-colors.ts @@ -0,0 +1,24 @@ +import type { MenuAction } from "@react-native-menu/menu"; + +// MenuView's iOS bridge treats an omitted imageColor as transparent. +export function withMenuActionIconColors( + actions: readonly MenuAction[], + colors: { + readonly icon: MenuAction["imageColor"]; + readonly destructiveIcon: MenuAction["imageColor"]; + }, +): MenuAction[] { + return actions.map((action) => ({ + ...action, + ...(action.image + ? { + imageColor: + action.imageColor ?? + (action.attributes?.destructive ? colors.destructiveIcon : colors.icon), + } + : {}), + ...(action.subactions + ? { subactions: withMenuActionIconColors(action.subactions, colors) } + : {}), + })); +} diff --git a/apps/mobile/src/lib/mobileBranding.test.ts b/apps/mobile/src/lib/mobileBranding.test.ts deleted file mode 100644 index 48a84b3f9857..000000000000 --- a/apps/mobile/src/lib/mobileBranding.test.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { resolveMobileStageLabel } from "./mobileBranding"; - -describe("resolveMobileStageLabel", () => { - it.each([ - ["development", "Dev"], - ["preview", "Nightly"], - ["production", "Alpha"], - [undefined, "Alpha"], - ])("maps %s builds to %s", (appVariant, expected) => { - expect(resolveMobileStageLabel(appVariant)).toBe(expected); - }); -}); diff --git a/apps/mobile/src/lib/mobileTheme.test-support.ts b/apps/mobile/src/lib/mobileTheme.test-support.ts new file mode 100644 index 000000000000..a702bb9afb27 --- /dev/null +++ b/apps/mobile/src/lib/mobileTheme.test-support.ts @@ -0,0 +1,20 @@ +import * as NodeFS from "node:fs"; + +import type { MobileThemeAppearance, MobileThemeVariables } from "./mobileTheme"; + +export function readDefaultMobileThemeVariables( + appearance: MobileThemeAppearance, +): MobileThemeVariables { + const stylesheet = NodeFS.readFileSync(new URL("../../global.css", import.meta.url), "utf8"); + const variant = new RegExp(`@variant ${appearance} \\{([\\s\\S]*?)\\n \\}`, "u").exec( + stylesheet, + )?.[1]; + if (variant === undefined) throw new Error(`Missing default ${appearance} theme in global.css.`); + + return Object.fromEntries( + Array.from(variant.matchAll(/(--color-[a-z0-9-]+):\s*([^;]+);/gu), ([, name, value]) => [ + name, + value.trim(), + ]), + ) as MobileThemeVariables; +} diff --git a/apps/mobile/src/lib/mobileTheme.test.ts b/apps/mobile/src/lib/mobileTheme.test.ts index d5744952bba4..a3c6712abae8 100644 --- a/apps/mobile/src/lib/mobileTheme.test.ts +++ b/apps/mobile/src/lib/mobileTheme.test.ts @@ -1,8 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; -import * as NodeFS from "node:fs"; - import { BUILT_IN_THEME_IDS, BUILT_IN_THEMES } from "@t3tools/shared/themePalettes"; -import { DEFAULT_MOBILE_THEME_VARIABLES } from "./mobileDefaultTheme"; +import { readDefaultMobileThemeVariables } from "./mobileTheme.test-support"; import { createMobileThemePairPatch, @@ -11,7 +9,6 @@ import { DEFAULT_MOBILE_THEME_ID, getMobileThemePreviewColors, getMobileThemeVariables, - MOBILE_THEME_IDS, normalizeMobileThemeId, normalizeMobileThemeMode, resolveMobileThemeIds, @@ -52,13 +49,12 @@ function compositeOver(overlay: string, background: string): string { describe("mobile themes", () => { it("declares every runtime theme variable in the static stylesheet", () => { - const stylesheet = NodeFS.readFileSync(new URL("../../global.css", import.meta.url), "utf8"); - const stylesheetVariables = new Set( - Array.from(stylesheet.matchAll(/--color-[a-z0-9-]+/g), ([variable]) => variable), + const generatedVariables = createMobileThemeVariables(BUILT_IN_THEMES[0].colors, "light"); + expect(Object.keys(readDefaultMobileThemeVariables("light")).sort()).toEqual( + Object.keys(generatedVariables).sort(), ); - - expect(Array.from(stylesheetVariables).sort()).toEqual( - Object.keys(DEFAULT_MOBILE_THEME_VARIABLES.light).sort(), + expect(Object.keys(readDefaultMobileThemeVariables("dark")).sort()).toEqual( + Object.keys(generatedVariables).sort(), ); }); @@ -71,17 +67,11 @@ describe("mobile themes", () => { }); it("preserves the existing mobile palette as the default", () => { - expect(getMobileThemeVariables(DEFAULT_MOBILE_THEME_ID, "light")["--color-screen"]).toBe( - "#f2f2f7", + expect(readDefaultMobileThemeVariables("light")["--color-screen"]).toBe("#f2f2f7"); + expect(readDefaultMobileThemeVariables("dark")["--color-screen"]).toBe("#0a0a0a"); + expect(readDefaultMobileThemeVariables("light")["--color-user-bubble-skill-foreground"]).toBe( + "#f0abfc", ); - expect(getMobileThemeVariables(DEFAULT_MOBILE_THEME_ID, "dark")["--color-screen"]).toBe( - "#0a0a0a", - ); - expect( - getMobileThemeVariables(DEFAULT_MOBILE_THEME_ID, "light")[ - "--color-user-bubble-skill-foreground" - ], - ).toBe("#f0abfc"); }); it("applies palette overrides on top of the selected built-in theme", () => { @@ -172,17 +162,11 @@ describe("mobile themes", () => { expect(variables["--color-backdrop"]).toBe("rgba(0, 0, 0, 0.22)"); expect(variables["--color-drawer-shadow"]).toBe("rgba(0, 0, 0, 0.12)"); expect(variables["--color-user-bubble-foreground"]).toMatch(/^#/); - expect(Object.keys(DEFAULT_MOBILE_THEME_VARIABLES.light).sort()).toEqual( - Object.keys(variables).sort(), - ); - expect(Object.keys(DEFAULT_MOBILE_THEME_VARIABLES.dark).sort()).toEqual( - Object.keys(variables).sort(), - ); }); it("keeps every built-in shadow and backdrop black-based in dark mode", () => { - for (const theme of BUILT_IN_THEMES) { - const variables = getMobileThemeVariables(normalizeMobileThemeId(theme.id), "dark"); + for (const themeId of BUILT_IN_THEME_IDS) { + const variables = getMobileThemeVariables(themeId, "dark"); expect(variables["--color-primary-shadow"]).toBe("#000000"); expect(variables["--color-backdrop"]).toBe("rgba(0, 0, 0, 0.48)"); expect(variables["--color-drawer-shadow"]).toBe("rgba(0, 0, 0, 0.32)"); @@ -190,7 +174,7 @@ describe("mobile themes", () => { }); it("keeps placeholders and selected-row labels readable on their mobile surfaces", () => { - for (const themeId of MOBILE_THEME_IDS) { + for (const themeId of BUILT_IN_THEME_IDS) { for (const appearance of ["light", "dark"] as const) { const variables = getMobileThemeVariables(themeId, appearance); expect( diff --git a/apps/mobile/src/lib/mobileTheme.ts b/apps/mobile/src/lib/mobileTheme.ts index 36de7f979da6..23034511287e 100644 --- a/apps/mobile/src/lib/mobileTheme.ts +++ b/apps/mobile/src/lib/mobileTheme.ts @@ -3,6 +3,7 @@ import { getThemeColorsForAppearance, MOBILE_DEFAULT_THEME_ID, MOBILE_THEME_IDS as SHARED_MOBILE_THEME_IDS, + type BuiltInThemeId, type MobileThemeId as SharedMobileThemeId, type ThemeAppearance, type ThemeColors, @@ -11,7 +12,6 @@ import { STANDARD_THEME_PREVIEW_COLORS, type ThemePreviewColors, } from "@t3tools/shared/themePreview"; -import { DEFAULT_MOBILE_THEME_VARIABLES } from "./mobileDefaultTheme"; export const DEFAULT_MOBILE_THEME_ID = MOBILE_DEFAULT_THEME_ID; export const MOBILE_THEME_IDS = SHARED_MOBILE_THEME_IDS; @@ -28,7 +28,7 @@ export const MOBILE_THEME_OPTIONS: ReadonlyArray<{ ...BUILT_IN_THEMES.map((theme) => ({ id: theme.id as MobileThemeId, label: theme.label })), ]; -type MobileThemeVariable = `--color-${string}`; +export type MobileThemeVariable = `--color-${string}`; export type MobileThemeVariables = Readonly>; export function normalizeMobileThemeId(value: unknown): MobileThemeId { @@ -282,18 +282,18 @@ export function createMobileThemeVariables( }; } +export const MOBILE_THEME_VARIABLE_NAMES = Object.keys( + createMobileThemeVariables(BUILT_IN_THEMES[0].colors, "light"), +) as ReadonlyArray; + export function getMobileThemeVariables( - themeId: MobileThemeId, + themeId: BuiltInThemeId, appearance: MobileThemeAppearance, overrides: Partial | null = null, ): MobileThemeVariables { - const baseVariables = (() => { - if (themeId === DEFAULT_MOBILE_THEME_ID) return DEFAULT_MOBILE_THEME_VARIABLES[appearance]; - const theme = - BUILT_IN_THEMES.find((candidate) => candidate.id === themeId) ?? BUILT_IN_THEMES[0]; - const colors = getThemeColorsForAppearance(theme, appearance) ?? theme.colors; - return createMobileThemeVariables(colors, appearance); - })(); + const theme = BUILT_IN_THEMES.find((candidate) => candidate.id === themeId) ?? BUILT_IN_THEMES[0]; + const colors = getThemeColorsForAppearance(theme, appearance) ?? theme.colors; + const baseVariables = createMobileThemeVariables(colors, appearance); // The complete base record guarantees that optional overrides cannot leave a token undefined. return overrides ? ({ ...baseVariables, ...overrides } as MobileThemeVariables) : baseVariables; diff --git a/apps/mobile/src/lib/mobileThemeRuntime.test.ts b/apps/mobile/src/lib/mobileThemeRuntime.test.ts new file mode 100644 index 000000000000..ad678d1f8815 --- /dev/null +++ b/apps/mobile/src/lib/mobileThemeRuntime.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + createMobileThemeRuntimeOperations, + getMobileUniwindThemeName, + type MobileThemeRuntimeState, +} from "./mobileThemeRuntime"; + +const initialState: MobileThemeRuntimeState = { + baseFontSize: 16, + themeAppearance: "light", + themeMode: "system", +}; + +describe("mobileThemeRuntime", () => { + it("keeps the default palette on Uniwind's built-in appearance themes", () => { + expect(getMobileUniwindThemeName("t3-code", "light")).toBe("light"); + expect(getMobileUniwindThemeName("t3-code", "dark")).toBe("dark"); + }); + + it("maps custom palettes and appearances to registered themes", () => { + expect(getMobileUniwindThemeName("t3-chat", "dark")).toBe("t3-chat-dark"); + }); + + it("hydrates text variables and clears the native appearance override", () => { + const operations = createMobileThemeRuntimeOperations(null, initialState); + const variableOperations = operations.filter( + (operation) => operation.kind === "update-text-variables", + ); + + expect(variableOperations).toHaveLength(12); + expect(variableOperations.at(-1)?.themeName).toBe("iris-dark"); + expect(operations.at(-1)).toEqual({ + kind: "set-appearance-mode", + appearance: "light", + themeMode: "system", + }); + }); + + it("lets system appearance changes flow through the root ScopedTheme only", () => { + const operations = createMobileThemeRuntimeOperations(initialState, { + ...initialState, + themeAppearance: "dark", + }); + + expect(operations).toEqual([]); + }); + + it("updates native appearance once when the selected mode changes", () => { + const operations = createMobileThemeRuntimeOperations(initialState, { + ...initialState, + themeAppearance: "dark", + themeMode: "dark", + }); + + expect(operations).toEqual([ + { + kind: "set-appearance-mode", + appearance: "dark", + themeMode: "dark", + }, + ]); + }); + + it("updates text variables for every theme without switching palettes", () => { + const operations = createMobileThemeRuntimeOperations(initialState, { + ...initialState, + baseFontSize: 18, + }); + + expect(operations).toHaveLength(12); + expect(operations.every((operation) => operation.kind === "update-text-variables")).toBe(true); + expect(operations.at(-1)).toMatchObject({ + kind: "update-text-variables", + themeName: "iris-dark", + }); + }); + + it("does no native work when persistence echoes an already-applied state", () => { + expect(createMobileThemeRuntimeOperations(initialState, initialState)).toEqual([]); + }); +}); diff --git a/apps/mobile/src/lib/mobileThemeRuntime.ts b/apps/mobile/src/lib/mobileThemeRuntime.ts new file mode 100644 index 000000000000..0c30de6bd46b --- /dev/null +++ b/apps/mobile/src/lib/mobileThemeRuntime.ts @@ -0,0 +1,75 @@ +import { resolveTextScaleVariables } from "./appearancePreferences"; +import { BUILT_IN_THEME_IDS, type BuiltInThemeId } from "@t3tools/shared/themePalettes"; +import { + DEFAULT_MOBILE_THEME_ID, + type MobileThemeAppearance, + type MobileThemeId, + type MobileThemeMode, +} from "./mobileTheme"; + +export type MobileUniwindThemeName = + | MobileThemeAppearance + | `${BuiltInThemeId}-${MobileThemeAppearance}`; + +export interface MobileThemeRuntimeState { + readonly baseFontSize: number; + readonly themeAppearance: MobileThemeAppearance; + readonly themeMode: MobileThemeMode; +} + +export type MobileThemeRuntimeOperation = + | { + readonly kind: "update-text-variables"; + readonly themeName: "light" | "dark" | MobileUniwindThemeName; + readonly variables: Readonly>; + } + | { + readonly kind: "set-appearance-mode"; + readonly appearance: MobileThemeAppearance; + readonly themeMode: MobileThemeMode; + }; + +const UNIWIND_THEME_NAMES: ReadonlyArray<"light" | "dark" | MobileUniwindThemeName> = [ + "light", + "dark", + ...BUILT_IN_THEME_IDS.flatMap((themeId) => [ + `${themeId}-light` as const, + `${themeId}-dark` as const, + ]), +]; + +export function getMobileUniwindThemeName( + themeId: MobileThemeId, + appearance: MobileThemeAppearance, +): MobileUniwindThemeName { + return themeId === DEFAULT_MOBILE_THEME_ID ? appearance : `${themeId}-${appearance}`; +} + +/** + * Plans imperative runtime work separately from theme selection. Palette + * changes are handled by one root ScopedTheme render; only typography and the + * native appearance override need imperative Uniwind/React Native updates. + */ +export function createMobileThemeRuntimeOperations( + previous: MobileThemeRuntimeState | null, + next: MobileThemeRuntimeState, +): ReadonlyArray { + const operations: MobileThemeRuntimeOperation[] = []; + + if (previous === null || previous.baseFontSize !== next.baseFontSize) { + const variables = resolveTextScaleVariables(next.baseFontSize); + for (const themeName of UNIWIND_THEME_NAMES) { + operations.push({ kind: "update-text-variables", themeName, variables }); + } + } + + if (previous === null || previous.themeMode !== next.themeMode) { + operations.push({ + kind: "set-appearance-mode", + appearance: next.themeAppearance, + themeMode: next.themeMode, + }); + } + + return operations; +} diff --git a/apps/mobile/src/lib/mobileThemeVariables.test.ts b/apps/mobile/src/lib/mobileThemeVariables.test.ts new file mode 100644 index 000000000000..78c002b2d5ad --- /dev/null +++ b/apps/mobile/src/lib/mobileThemeVariables.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { readDefaultMobileThemeVariables } from "./mobileTheme.test-support"; +import { getMobileThemeVariables } from "./mobileTheme"; +import { getMobileThemeRuntimeVariables } from "./mobileThemeVariables"; + +describe("mobile theme runtime variables", () => { + it("derives the standard runtime palette from global.css", () => { + expect(getMobileThemeRuntimeVariables("t3-code", "light")).toEqual( + readDefaultMobileThemeVariables("light"), + ); + expect(getMobileThemeRuntimeVariables("t3-code", "dark")).toEqual( + readDefaultMobileThemeVariables("dark"), + ); + }); + + it("uses the same shared palette source as generated custom themes", () => { + expect(getMobileThemeRuntimeVariables("ocean", "light")).toEqual( + getMobileThemeVariables("ocean", "light"), + ); + expect(getMobileThemeRuntimeVariables("iris", "dark")).toEqual( + getMobileThemeVariables("iris", "dark"), + ); + }); +}); diff --git a/apps/mobile/src/lib/mobileThemeVariables.ts b/apps/mobile/src/lib/mobileThemeVariables.ts new file mode 100644 index 000000000000..79a479b4fe61 --- /dev/null +++ b/apps/mobile/src/lib/mobileThemeVariables.ts @@ -0,0 +1,27 @@ +import defaultThemeVariables from "../../generated-uniwind-default-theme-variables.json"; + +import { + DEFAULT_MOBILE_THEME_ID, + getMobileThemeVariables, + type MobileThemeAppearance, + type MobileThemeId, + type MobileThemeVariables, +} from "./mobileTheme"; + +const defaults = defaultThemeVariables as Readonly< + Record +>; + +/** + * Complete palette for native and third-party APIs that cannot consume a + * Uniwind className. The standard palette is generated from global.css; custom + * palettes share the same source that generates their registered CSS themes. + */ +export function getMobileThemeRuntimeVariables( + themeId: MobileThemeId, + appearance: MobileThemeAppearance, +): MobileThemeVariables { + return themeId === DEFAULT_MOBILE_THEME_ID + ? defaults[appearance] + : getMobileThemeVariables(themeId, appearance); +} diff --git a/apps/mobile/src/lib/modelOptions.test.ts b/apps/mobile/src/lib/modelOptions.test.ts index 8a9dabbe034f..aafc49e36024 100644 --- a/apps/mobile/src/lib/modelOptions.test.ts +++ b/apps/mobile/src/lib/modelOptions.test.ts @@ -1,12 +1,14 @@ import { describe, expect, it } from "vite-plus/test"; -import { ProviderInstanceId, type ServerConfig } from "@t3tools/contracts"; +import { ProviderInstanceId, type ModelSelection, type ServerConfig } from "@t3tools/contracts"; import { buildModelOptions, groupByProvider, resolveDefaultableModelSelection, + resolveNewTaskModelSelection, resolveSelectableModelSelection, + type ModelOption, } from "./modelOptions"; describe("mobile model options", () => { @@ -44,13 +46,62 @@ describe("mobile model options", () => { providerKey: "codex", providerLabel: "Codex", models: [ - { key: "codex:gpt-5.6-sol", label: "GPT-5.6 Sol", isLegacy: false }, + { key: "codex:gpt-5.6-sol", label: "GPT-5.6 Sol", subtitle: "", isLegacy: false }, { key: "codex:gpt-5.4", label: "GPT-5.4", isLegacy: true }, ], }, ]); }); + it("distinguishes same-name OpenCode models without changing their routing", () => { + const sources = [ + { id: "anthropic", label: "Anthropic" }, + { id: "github-copilot", label: "GitHub Copilot" }, + { id: "opencode", label: "OpenCode Zen" }, + ]; + const config = { + providers: [ + { + instanceId: "opencode_work", + driver: "opencode", + displayName: "OpenCode Work", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + models: sources.map((source) => ({ + slug: `${source.id}/claude-fable-5`, + name: "Claude Fable 5", + subProvider: source.label, + isCustom: false, + capabilities: null, + })), + }, + ], + } as unknown as ServerConfig; + const selection = { + instanceId: ProviderInstanceId.make("opencode_work"), + model: "github-copilot/claude-fable-5", + }; + + const options = buildModelOptions(config, selection); + + expect(options).toMatchObject( + sources.map((source) => ({ + key: `opencode_work:${source.id}/claude-fable-5`, + label: "Claude Fable 5", + subtitle: source.label, + providerLabel: "OpenCode Work", + selection: { + instanceId: "opencode_work", + model: `${source.id}/claude-fable-5`, + }, + })), + ); + expect(groupByProvider(options)).toEqual([ + { providerKey: "opencode_work", providerLabel: "OpenCode Work", models: options }, + ]); + }); + it("normalizes a legacy fallback selection against current capabilities", () => { const config = { providers: [ @@ -171,4 +222,30 @@ describe("mobile model options", () => { // Offline: nothing to validate against, selection passes through. expect(resolveDefaultableModelSelection(null, legacy)).toBe(legacy); }); + + it("resolves new tasks from draft, project, sticky, then provider defaults", () => { + const draft = { instanceId: ProviderInstanceId.make("codex"), model: "draft" }; + const project = { instanceId: ProviderInstanceId.make("codex"), model: "project" }; + const sticky = { instanceId: ProviderInstanceId.make("codex"), model: "sticky" }; + const providerDefault = { + selection: { instanceId: ProviderInstanceId.make("codex"), model: "default" }, + isDefault: true, + } as ModelOption; + const resolve = ( + draftSelection: ModelSelection | null, + projectDefaultSelection: ModelSelection | null, + stickySelection: ModelSelection | null, + ) => + resolveNewTaskModelSelection({ + draftSelection, + projectDefaultSelection, + stickySelection, + modelOptions: [providerDefault], + }); + + expect(resolve(draft, project, sticky)).toBe(draft); + expect(resolve(null, project, sticky)).toBe(project); + expect(resolve(null, null, sticky)).toBe(sticky); + expect(resolve(null, null, null)).toBe(providerDefault.selection); + }); }); diff --git a/apps/mobile/src/lib/modelOptions.ts b/apps/mobile/src/lib/modelOptions.ts index cb7a8c4198ec..26ffd6855582 100644 --- a/apps/mobile/src/lib/modelOptions.ts +++ b/apps/mobile/src/lib/modelOptions.ts @@ -104,6 +104,22 @@ export function resolveDefaultableModelSelection( return model?.isLegacy === true ? null : usable; } +export function resolveNewTaskModelSelection(input: { + readonly draftSelection: ModelSelection | null; + readonly projectDefaultSelection: ModelSelection | null; + readonly stickySelection: ModelSelection | null; + readonly modelOptions: ReadonlyArray; +}): ModelSelection | null { + return ( + input.draftSelection ?? + input.projectDefaultSelection ?? + input.stickySelection ?? + input.modelOptions.find((option) => option.isDefault)?.selection ?? + input.modelOptions[0]?.selection ?? + null + ); +} + export function buildModelOptions( config: T3ServerConfig | null | undefined, fallbackModelSelection: ModelSelection | null, @@ -121,7 +137,7 @@ export function buildModelOptions( options.set(key, { key, label: model.name, - subtitle: providerLabel, + subtitle: model.subProvider ?? "", providerKey: provider.instanceId, providerLabel, providerDriver: provider.driver, @@ -152,7 +168,7 @@ export function buildModelOptions( options.set(key, { key, label: fallbackModelSelection.model, - subtitle: providerLabel, + subtitle: "", providerKey: fallbackModelSelection.instanceId, providerLabel, providerDriver: fallbackModelSelection.instanceId, diff --git a/apps/mobile/src/lib/nativeMarkdownText.test.ts b/apps/mobile/src/lib/nativeMarkdownText.test.ts index 867d9e983017..1e7cb5f3164e 100644 --- a/apps/mobile/src/lib/nativeMarkdownText.test.ts +++ b/apps/mobile/src/lib/nativeMarkdownText.test.ts @@ -11,6 +11,43 @@ import { } from "@t3tools/mobile-markdown-text/markdown"; describe("nativeMarkdownTextRuns", () => { + it("links a path-shaped code span without changing the same path in prose", () => { + expect( + nativeMarkdownTextRuns({ + type: "paragraph", + children: [ + { type: "text", content: "/tmp/frame.png " }, + { type: "code_inline", content: "/tmp/frame.png" }, + ], + }), + ).toEqual([ + { text: "/tmp/frame.png " }, + { text: "frame.png", href: "/tmp/frame.png", fileIcon: "image" }, + ]); + }); + + it("preserves the destination of a link with a code-formatted label", () => { + expect( + nativeMarkdownTextRuns({ + type: "paragraph", + children: [ + { + type: "link", + href: "https://example.com/docs", + children: [{ type: "code_inline", content: "src/main.ts" }], + }, + ], + }), + ).toEqual([ + { + text: "src/main.ts", + code: true, + href: "https://example.com/docs", + externalHost: "example.com", + }, + ]); + }); + it("preserves inline emphasis and code styles", () => { const node: MarkdownNode = { type: "paragraph", diff --git a/apps/mobile/src/lib/projectThreadStartTurn.ts b/apps/mobile/src/lib/projectThreadStartTurn.ts index 85523175a2f5..aac1abc4b81e 100644 --- a/apps/mobile/src/lib/projectThreadStartTurn.ts +++ b/apps/mobile/src/lib/projectThreadStartTurn.ts @@ -8,7 +8,8 @@ import { type RuntimeMode, } from "@t3tools/contracts"; -import { toUploadChatImageAttachments, type DraftComposerImageAttachment } from "./composerImages"; +import { toUploadChatImageAttachments, type DraftComposerAttachment } from "./composerImages"; +import type { UploadedMobileAttachment } from "./attachmentUpload"; export function deriveThreadTitleFromPrompt(value: string): string { const trimmed = value.trim(); @@ -28,7 +29,8 @@ export interface ProjectThreadStartTurnSpec { readonly messageId: string; readonly createdAt: string; readonly text: string; - readonly attachments: ReadonlyArray; + readonly attachments: ReadonlyArray; + readonly uploadedAttachments?: ReadonlyArray; readonly modelSelection: ModelSelection; readonly runtimeMode: RuntimeMode; readonly interactionMode: ProviderInteractionMode; @@ -55,7 +57,11 @@ export function buildProjectThreadStartTurnInput(spec: ProjectThreadStartTurnSpe messageId: MessageId.make(spec.messageId), role: "user" as const, text: spec.text, - attachments: toUploadChatImageAttachments(spec.attachments), + attachments: + spec.uploadedAttachments ?? + toUploadChatImageAttachments( + spec.attachments.filter((attachment) => attachment.type === "image"), + ), }, modelSelection: spec.modelSelection, titleSeed: title, diff --git a/apps/mobile/src/lib/runtime.ts b/apps/mobile/src/lib/runtime.ts index 98730edfbfca..a7f9a5dab1bd 100644 --- a/apps/mobile/src/lib/runtime.ts +++ b/apps/mobile/src/lib/runtime.ts @@ -9,6 +9,9 @@ import { managedRelayClientLayer } from "../features/cloud/managedRelayLayer"; import { resolveCloudPublicConfig } from "../features/cloud/publicConfig"; import { tracingLayer } from "../features/observability/tracing"; import * as Persistence from "../persistence/layer"; +import { disposeOnFoundationReplace, type FoundationHotModule } from "./foundation-fast-refresh"; + +declare const module: { readonly hot?: FoundationHotModule } | undefined; function configuredRelayUrl(): string { return resolveCloudPublicConfig().relay.url ?? "http://relay.invalid"; @@ -43,3 +46,7 @@ export const runtimeContextLayer: Layer.Layer< Layer.Success, Layer.Error > = Layer.effectContext(runtime.contextEffect); + +disposeOnFoundationReplace(typeof module === "undefined" ? undefined : module.hot, () => + runtime.dispose(), +); diff --git a/apps/mobile/src/lib/shareFileFromSource.ios.ts b/apps/mobile/src/lib/shareFileFromSource.ios.ts new file mode 100644 index 000000000000..5de0e0cbd423 --- /dev/null +++ b/apps/mobile/src/lib/shareFileFromSource.ios.ts @@ -0,0 +1,14 @@ +import { requireNativeModule } from "expo"; +import type { SharingOptions } from "expo-sharing"; + +const NativeControls = requireNativeModule<{ + shareFileFromSource(uri: string, title: string, sourceIdentifier: string): Promise; +}>("T3NativeControls"); + +export function shareFileFromSource( + uri: string, + options: SharingOptions, + sourceIdentifier: string, +) { + return NativeControls.shareFileFromSource(uri, options.dialogTitle ?? "", sourceIdentifier); +} diff --git a/apps/mobile/src/lib/shareFileFromSource.ts b/apps/mobile/src/lib/shareFileFromSource.ts new file mode 100644 index 000000000000..5e806612a046 --- /dev/null +++ b/apps/mobile/src/lib/shareFileFromSource.ts @@ -0,0 +1,9 @@ +import { shareAsync, type SharingOptions } from "expo-sharing"; + +export function shareFileFromSource( + uri: string, + options: SharingOptions, + _sourceIdentifier: string, +) { + return shareAsync(uri, options); +} diff --git a/apps/mobile/src/lib/storage.test.ts b/apps/mobile/src/lib/storage.test.ts index fe022c1191ae..a1c7960a570b 100644 --- a/apps/mobile/src/lib/storage.test.ts +++ b/apps/mobile/src/lib/storage.test.ts @@ -196,6 +196,12 @@ describe("mobile connection storage", () => { }); }); + it("drops the removed theme transition preference", async () => { + mocks.setPreferencesJson(JSON.stringify({ themeTransition: "circle-bottom-left" }), 10); + + await expect(loadPreferences()).resolves.toEqual({}); + }); + it("falls back to secure storage when SQLite cannot save preferences", async () => { mocks.setDatabaseFailures(true, true); await expect(savePreferencesPatch({ baseFontSize: 19 })).resolves.toEqual({ baseFontSize: 19 }); diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index e2943ebc1a0d..136b01190e31 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -234,6 +234,83 @@ function makeThread( } describe("buildThreadFeed", () => { + it("keeps setup failures visible without routine setup notices before or after a turn", () => { + const thread = makeThread({ + id: ThreadId.make("thread-worktree-setup"), + projectId: ProjectId.make("project-1"), + title: "Worktree setup", + activities: [ + makeActivity({ + id: EventId.make("setup-requested"), + kind: "setup-script.requested", + summary: "Starting setup script", + createdAt: "2026-08-30T00:00:00.000Z", + }), + makeActivity({ + id: EventId.make("setup-started"), + kind: "setup-script.started", + summary: "Setup script started", + createdAt: "2026-08-30T00:00:01.000Z", + }), + makeActivity({ + id: EventId.make("setup-failed"), + kind: "setup-script.failed", + summary: "Setup script failed to start", + createdAt: "2026-08-30T00:00:02.000Z", + tone: "error", + payload: { detail: "Setup command was not found" }, + }), + ], + }); + const latestTurn = { + turnId: TurnId.make("turn-after-setup"), + state: "running" as const, + requestedAt: "2026-08-30T00:00:03.000Z", + startedAt: "2026-08-30T00:00:04.000Z", + completedAt: null, + assistantMessageId: null, + }; + + for (const currentTurn of [null, latestTurn]) { + const feed = buildThreadFeed({ ...thread, latestTurn: currentTurn }); + expect(feed).toMatchObject([ + { + type: "activity-group", + activities: [{ id: "setup-failed", status: "failure" }], + }, + ]); + const group = feed[0]; + if (group?.type !== "activity-group") throw new Error("Expected the setup failure group"); + expect(group.activities[0]?.getCopyText()).toContain("Setup command was not found"); + } + }); + + it.each(["setup-script.requested", "setup-script.started"])( + "keeps error-toned %s notices visible", + (kind) => { + const feed = buildThreadFeed( + makeThread({ + id: ThreadId.make("thread-setup-error"), + projectId: ProjectId.make("project-1"), + title: "Setup error", + activities: [ + makeActivity({ + id: EventId.make("setup-error"), + kind, + summary: "Setup failed", + createdAt: "2026-08-30T00:00:00.000Z", + tone: "error", + }), + ], + }), + ); + + expect(feed).toMatchObject([ + { type: "activity-group", activities: [{ id: "setup-error", status: "failure" }] }, + ]); + }, + ); + it("keeps older local feedback before newer messages returned by the server", () => { const submission = { id: MessageId.make("feedback-command-ordering"), @@ -324,6 +401,38 @@ describe("buildThreadFeed", () => { ]); }); + it("drops runtime warnings with no displayable content", () => { + const thread = makeThread({ + id: ThreadId.make("thread-noise"), + projectId: ProjectId.make("project-1"), + title: "Warning noise thread", + activities: [ + makeActivity({ + id: EventId.make("activity-noise"), + kind: "runtime.warning", + summary: "Claude system message 'background_tasks_changed' (no displayable text content)", + createdAt: "2026-04-01T00:00:02.000Z", + turnId: TurnId.make("turn-1"), + }), + makeActivity({ + id: EventId.make("activity-signal"), + kind: "runtime.warning", + summary: "Reconnecting... 2/5", + createdAt: "2026-04-01T00:00:03.000Z", + turnId: TurnId.make("turn-1"), + }), + ], + }); + + const feed = buildThreadFeed(thread); + expect(feed).toMatchObject([ + { + type: "activity-group", + activities: [{ id: "activity-signal" }], + }, + ]); + }); + it("collapses matching tool lifecycle rows like desktop", () => { const thread = makeThread({ id: ThreadId.make("thread-2"), @@ -379,8 +488,8 @@ describe("buildThreadFeed", () => { expect(group.activities).toHaveLength(1); expect(group.activities[0]).toMatchObject({ - id: "tool-completed", - createdAt: "2026-04-01T00:00:02.000Z", + id: "tool-updated", + createdAt: "2026-04-01T00:00:01.000Z", turnId: "turn-1", summary: "Run tests", detail: "bun run test", @@ -560,7 +669,7 @@ describe("buildThreadFeed", () => { expect(expanded.map((entry) => entry.id)).toEqual([ "assistant-first", "turn-fold:turn-1", - "tool-completed", + "work-toggle:work-group:tool-completed", "assistant-final", ]); }); @@ -713,6 +822,20 @@ describe("buildThreadFeed", () => { assistantMessageId: null, }, activities: [ + makeActivity({ + id: EventId.make("tool-succeeded"), + kind: "tool.completed", + tone: "tool", + summary: "Run command", + createdAt: "2026-04-01T00:00:04.000Z", + turnId, + payload: { + title: "Run command", + itemType: "command_execution", + detail: "done", + status: "completed", + }, + }), makeActivity({ id: EventId.make("tool-failed"), kind: "tool.completed", @@ -731,25 +854,26 @@ describe("buildThreadFeed", () => { }); const feed = buildThreadFeed(thread); - expect(deriveThreadFeedPresentation(feed, thread.latestTurn, new Set())).toEqual(feed); - expect(feed[0]).toMatchObject({ - type: "activity-group", - activities: [{ status: "failure" }], - }); - }); - - it("appends active work as a normal timeline row", () => { - const startedAt = "2026-04-01T00:00:01.000Z"; - const presented = deriveThreadFeedPresentation([], null, new Set(), new Set(), startedAt); - - expect(presented).toEqual([ + expect(deriveThreadFeedPresentation(feed, thread.latestTurn, new Set())).toMatchObject([ { - type: "working", - id: "working-indicator-row", - createdAt: startedAt, + type: "work-toggle", + summary: "Ran 2 commands", + hiddenCount: 2, + hasFailure: true, }, ]); - expect(deriveThreadFeedPresentation(presented, null, new Set())).toEqual([]); + expect(feed[0]).toMatchObject({ + type: "activity-group", + activities: [{ status: "success" }, { status: "failure" }], + }); + expect( + deriveThreadFeedPresentation( + feed, + thread.latestTurn, + new Set(), + new Set(["work-group:tool-succeeded"]), + ).map((entry) => entry.id), + ).toEqual(["work-toggle:work-group:tool-succeeded", "tool-succeeded", "tool-failed"]); }); it("models work-log overflow as list rows", () => { @@ -769,6 +893,14 @@ describe("buildThreadFeed", () => { icon: "command", toolLike: true, status, + workEntry: { + id, + createdAt, + turnId: null, + label: `Tool ${id}`, + command: `command ${id}`, + tone: "tool", + }, }); const feed: ThreadFeedEntry[] = [ { @@ -786,26 +918,235 @@ describe("buildThreadFeed", () => { ]; const collapsed = deriveThreadFeedPresentation(feed, null, new Set()); - expect(collapsed.map((entry) => entry.id)).toEqual(["activity-3", "work-toggle:work-group-1"]); - expect(collapsed[1]).toMatchObject({ + expect(collapsed.map((entry) => entry.id)).toEqual(["work-toggle:work-group:activity-1"]); + expect(collapsed[0]).toMatchObject({ type: "work-toggle", - groupId: "work-group-1", - hiddenCount: 2, + groupId: "work-group:activity-1", + hiddenCount: 3, expanded: false, + summary: "Ran 3 commands", }); - const expanded = deriveThreadFeedPresentation(feed, null, new Set(), new Set(["work-group-1"])); + const expanded = deriveThreadFeedPresentation( + feed, + null, + new Set(), + new Set(["work-group:activity-1"]), + ); expect(expanded.map((entry) => entry.id)).toEqual([ + "work-toggle:work-group:activity-1", "activity-1", "activity-2", "activity-3", - "work-toggle:work-group-1", ]); - expect(expanded.at(-1)).toMatchObject({ + expect(expanded[0]).toMatchObject({ type: "work-toggle", expanded: true, }); }); + + it("keeps live state on the active uninterrupted tool run", () => { + const turnId = TurnId.make("turn-live-tools"); + const activity = ( + id: string, + status: ThreadFeedActivity["status"], + lifecycleStatus: ThreadFeedActivity["lifecycleStatus"], + tone: "tool" | "error" = "tool", + command?: string, + ): ThreadFeedActivity => ({ + id, + createdAt: `2026-04-01T00:00:0${id.at(-1)}.000Z`, + turnId, + summary: `Tool ${id}`, + detail: null, + canExpand: false, + getFullDetail: () => null, + getCopyText: () => id, + icon: "command", + toolLike: true, + status, + lifecycleStatus, + workEntry: { + id, + createdAt: `2026-04-01T00:00:0${id.at(-1)}.000Z`, + turnId, + label: `Tool ${id}`, + tone, + toolLifecycleStatus: lifecycleStatus, + ...(command ? { command, itemType: "command_execution" as const } : {}), + }, + }); + const feed: ThreadFeedEntry[] = [ + { + type: "activity-group", + id: "activity-1", + createdAt: "2026-04-01T00:00:01.000Z", + turnId, + activities: [ + activity("activity-1", "success", "completed"), + activity("activity-2", "failure", "failed", "error"), + activity("activity-3", "success", "completed", "tool", "sudo -u root pnpm test"), + ], + }, + ]; + const latestTurn = { + turnId, + state: "running" as const, + requestedAt: "2026-04-01T00:00:00.000Z", + startedAt: "2026-04-01T00:00:00.000Z", + completedAt: null, + assistantMessageId: null, + }; + + const rows = deriveThreadFeedPresentation( + feed, + latestTurn, + new Set(), + new Set(), + latestTurn.startedAt, + ); + expect(rows.slice(0, 3).map((entry) => [entry.id, entry.type])).toEqual([ + ["work-toggle:work-group:activity-1", "work-toggle"], + ["activity-2", "activity-group"], + ["work-live:work-group:activity-3", "work-toggle"], + ]); + expect(rows.slice(0, 3).map((entry) => entry.type === "work-toggle" && entry.live)).toEqual([ + false, + false, + true, + ]); + expect(rows[2]).toMatchObject({ + summary: "Running pnpm", + summaryKind: "command", + live: true, + shimmer: true, + }); + expect(rows[0]).toMatchObject({ live: false, shimmer: false }); + + const stoppedRows = deriveThreadFeedPresentation(feed, latestTurn, new Set()); + expect(stoppedRows.filter((entry) => entry.type === "work-toggle")).toMatchObject([ + { live: false, shimmer: false }, + { live: false, shimmer: false }, + ]); + + const completedRows = deriveThreadFeedPresentation( + feed, + { ...latestTurn, state: "completed", completedAt: "2026-04-01T00:00:04.000Z" }, + new Set([turnId]), + new Set(), + latestTurn.startedAt, + ); + expect(completedRows.filter((entry) => entry.type === "work-toggle")).toMatchObject([ + { live: false, shimmer: false }, + { live: false, shimmer: false }, + ]); + }); + + it("does not revive cached in-progress tools after work stops", () => { + const turnId = TurnId.make("turn-stale-tool"); + const feed: ThreadFeedEntry[] = [ + { + type: "activity-group", + id: "stale-tool", + createdAt: "2026-04-01T00:00:01.000Z", + turnId, + activities: [ + { + id: "stale-tool", + createdAt: "2026-04-01T00:00:01.000Z", + turnId, + summary: "Running tests", + detail: null, + canExpand: false, + getFullDetail: () => null, + getCopyText: () => "", + icon: "command", + toolLike: true, + status: "neutral", + lifecycleStatus: "inProgress", + workEntry: { + id: "stale-tool", + createdAt: "2026-04-01T00:00:01.000Z", + turnId, + label: "Running tests", + tone: "tool", + toolLifecycleStatus: "inProgress", + }, + }, + ], + }, + ]; + const latestTurn = { + turnId, + state: "running" as const, + requestedAt: "2026-04-01T00:00:00.000Z", + startedAt: "2026-04-01T00:00:00.000Z", + completedAt: null, + assistantMessageId: null, + }; + + expect(deriveThreadFeedPresentation(feed, latestTurn, new Set())).toEqual([]); + expect( + deriveThreadFeedPresentation(feed, latestTurn, new Set(), new Set(), latestTurn.startedAt), + ).toMatchObject([{ type: "work-toggle", live: true, shimmer: true }]); + }); + + it("collapses interleaved tool lifecycles by call identity", () => { + const turnId = TurnId.make("turn-parallel-tools"); + const toolActivity = ( + id: string, + toolCallId: string, + kind: "tool.updated" | "tool.completed", + status: "inProgress" | "completed", + detail: string, + nestedId = false, + ) => + makeActivity({ + id: EventId.make(id), + kind, + tone: "tool", + summary: `Run ${toolCallId} command`, + createdAt: `2026-04-01T00:00:0${id.at(-1)}.000Z`, + turnId, + payload: { + ...(nestedId ? { data: { toolCallId } } : { toolCallId }), + itemType: "command_execution", + status, + detail, + }, + }); + const thread = makeThread({ + id: ThreadId.make("thread-parallel-tools"), + projectId: ProjectId.make("project-1"), + title: "Parallel tools", + activities: [ + toolActivity("call-a-1", "call-a", "tool.updated", "inProgress", "starting"), + toolActivity("call-b-2", "call-b", "tool.updated", "inProgress", "starting", true), + toolActivity("call-a-3", "call-a", "tool.completed", "completed", "first output"), + toolActivity("call-b-4", "call-b", "tool.completed", "completed", "second output", true), + ], + }); + + const feed = buildThreadFeed(thread); + const activityGroup = feed.find((entry) => entry.type === "activity-group"); + expect(activityGroup).toMatchObject({ + type: "activity-group", + activities: [ + { id: "call-a-1", lifecycleStatus: "completed", detail: "first output" }, + { id: "call-b-2", lifecycleStatus: "completed", detail: "second output" }, + ], + }); + expect( + deriveThreadFeedPresentation(feed, null, new Set([turnId])).find( + (entry) => entry.type === "work-toggle", + ), + ).toMatchObject({ + type: "work-toggle", + hiddenCount: 2, + summary: "Ran 2 commands", + live: false, + }); + }); }); describe("quiet timeline: nested agents", () => { @@ -841,5 +1182,8 @@ describe("quiet timeline: nested agents", () => { ); expect(ids).toContain("nested-done"); expect(ids).not.toContain("shell-done"); + expect(deriveThreadFeedPresentation(feed, null, new Set())).toMatchObject([ + { type: "activity-group", id: "nested-done" }, + ]); }); }); diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index 9e0cb64ae8b3..367042448dac 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -13,6 +13,15 @@ import type { UserInputQuestion, } from "@t3tools/contracts"; import { formatDuration } from "@t3tools/shared/orchestrationTiming"; +import { + isWorktreeSetupActivity, + normalizeCompactToolLabel, + omitSupersededLifecycleMarkers, + summarizeToolGroup, + toolGroupSummaryKind, + type ToolGroupSummaryKind, +} from "@t3tools/client-runtime/work-log/presentation"; +import { commandProgramName } from "@t3tools/client-runtime/work-log/command-label"; import * as Arr from "effect/Array"; import * as Order from "effect/Order"; @@ -65,13 +74,15 @@ export interface ThreadFeedActivity { | "zap"; readonly toolLike: boolean; readonly status: "success" | "failure" | "neutral" | null; + readonly lifecycleStatus?: WorkLogToolLifecycleStatus; + readonly workEntry: WorkLogEntry; + readonly groupedToolDetail?: boolean; + readonly live?: boolean; } -const MAX_VISIBLE_WORK_LOG_ENTRIES = 1; - type WorkLogToolLifecycleStatus = "inProgress" | "completed" | "failed" | "declined" | "stopped"; -interface WorkLogEntry { +export interface WorkLogEntry { id: string; createdAt: string; turnId: TurnId | null; @@ -85,11 +96,14 @@ interface WorkLogEntry { itemType?: ToolLifecycleItemType; requestKind?: PendingApproval["requestKind"]; toolLifecycleStatus?: WorkLogToolLifecycleStatus; + sourceActivityKind?: OrchestrationThreadActivity["kind"]; + toolCallId?: string; + agentSpawn?: boolean; toolData?: unknown; } interface DerivedWorkLogEntry extends WorkLogEntry { - activityKind: OrchestrationThreadActivity["kind"]; + sourceActivityKind: OrchestrationThreadActivity["kind"]; collapseKey?: string; /** Grouping key for subagent lifecycle rows (one row per agent). */ taskId?: string; @@ -112,11 +126,6 @@ type RawThreadFeedEntry = export type ThreadFeedEntry = | Extract - | { - readonly type: "working"; - readonly id: string; - readonly createdAt: string; - } | { readonly type: "activity-group"; readonly id: string; @@ -132,7 +141,11 @@ export type ThreadFeedEntry = readonly groupId: string; readonly hiddenCount: number; readonly expanded: boolean; - readonly onlyToolActivities: boolean; + readonly summary: string; + readonly summaryKind: ToolGroupSummaryKind; + readonly hasFailure: boolean; + readonly live: boolean; + readonly shimmer: boolean; } | { readonly type: "turn-fold"; @@ -331,6 +344,7 @@ function deriveWorkLogEntries( const ordered = Arr.sort(activities, activityOrder); const entries: DerivedWorkLogEntry[] = []; for (const activity of ordered) { + if (activity.tone !== "error" && isWorktreeSetupActivity(activity.kind)) continue; if (activity.kind === "tool.started") continue; if (activity.kind === "task.started") continue; // Terminal bypassed updates pass: Codex children's only terminal signal. @@ -338,6 +352,7 @@ function deriveWorkLogEntries( if (activity.kind === "tool.progress") continue; if (activity.kind === "context-window.updated") continue; if (activity.summary === "Checkpoint captured") continue; + if (isNoContentRuntimeWarning(activity)) continue; if (isPlanBoundaryToolActivity(activity)) continue; if (isAgentInternalActivity(activity)) continue; entries.push(toDerivedWorkLogEntry(activity)); @@ -345,6 +360,17 @@ function deriveWorkLogEntries( return collapseDerivedWorkLogEntries(entries); } +/** Adapters forward unknown wire-only SDK messages (background_tasks_changed, + * commands_changed, ...) as runtime warnings. The suffix comes from + * describeUnknownSdkMessage in the Claude adapter; a row with no displayable + * text carries nothing a user can act on, so it does not render. */ +function isNoContentRuntimeWarning(activity: OrchestrationThreadActivity): boolean { + return ( + activity.kind === "runtime.warning" && + activity.summary.endsWith("(no displayable text content)") + ); +} + function isPlanBoundaryToolActivity(activity: OrchestrationThreadActivity): boolean { if (activity.kind !== "tool.updated" && activity.kind !== "tool.completed") { return false; @@ -400,8 +426,16 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo : activity.tone === "approval" ? "info" : activity.tone, - activityKind: activity.kind, + sourceActivityKind: activity.kind, }; + const toolCallId = + asTrimmedString(payload?.toolCallId) ?? asTrimmedString(asRecord(payload?.data)?.toolCallId); + if (toolCallId) { + entry.toolCallId = toolCallId; + } + if (isTaskActivity && payload?.agentKind === "agent") { + entry.agentSpawn = true; + } const itemType = extractWorkLogItemType(payload); const requestKind = extractWorkLogRequestKind(payload); if ( @@ -460,12 +494,13 @@ function collapseDerivedWorkLogEntries( // Subagent rows collapse by identity, not adjacency (quiet-timeline // guarantee; mirrors web's session-logic). const taskRowIndex = new Map(); + const toolLifecycleRowIndex = new Map(); for (const entry of entries) { const isTaskRow = entry.taskId !== undefined && - (entry.activityKind === "task.progress" || - entry.activityKind === "task.completed" || - entry.activityKind === "task.updated"); + (entry.sourceActivityKind === "task.progress" || + entry.sourceActivityKind === "task.completed" || + entry.sourceActivityKind === "task.updated"); if (isTaskRow && entry.taskId !== undefined) { const existingIndex = taskRowIndex.get(entry.taskId); if (existingIndex !== undefined) { @@ -476,30 +511,78 @@ function collapseDerivedWorkLogEntries( collapsed.push(entry); continue; } + const lifecycleKey = toolLifecycleCollapseMapKey(entry); + if (lifecycleKey !== undefined) { + const matchingIndex = toolLifecycleRowIndex.get(lifecycleKey); + const matchingEntry = matchingIndex === undefined ? undefined : collapsed[matchingIndex]; + if ( + matchingIndex !== undefined && + matchingEntry && + shouldCollapseToolLifecycleEntries(matchingEntry, entry) + ) { + collapsed[matchingIndex] = mergeDerivedWorkLogEntries(matchingEntry, entry); + continue; + } + toolLifecycleRowIndex.delete(lifecycleKey); + } const previous = collapsed.at(-1); if (previous && shouldCollapseToolLifecycleEntries(previous, entry)) { - collapsed[collapsed.length - 1] = mergeDerivedWorkLogEntries(previous, entry); + const previousIndex = collapsed.length - 1; + const previousKey = toolLifecycleCollapseMapKey(previous); + if (previousKey !== undefined) toolLifecycleRowIndex.delete(previousKey); + const merged = mergeDerivedWorkLogEntries(previous, entry); + collapsed[previousIndex] = merged; + const mergedKey = toolLifecycleCollapseMapKey(merged); + if (mergedKey !== undefined) toolLifecycleRowIndex.set(mergedKey, previousIndex); continue; } collapsed.push(entry); + if (lifecycleKey !== undefined) { + toolLifecycleRowIndex.set(lifecycleKey, collapsed.length - 1); + } } return collapsed; } +function toolLifecycleCollapseMapKey(entry: DerivedWorkLogEntry): string | undefined { + if ( + entry.sourceActivityKind !== "tool.updated" && + entry.sourceActivityKind !== "tool.completed" + ) { + return undefined; + } + return entry.toolCallId ? `tool:${entry.turnId ?? "no-turn"}:${entry.toolCallId}` : undefined; +} + function shouldCollapseToolLifecycleEntries( previous: DerivedWorkLogEntry, next: DerivedWorkLogEntry, ): boolean { - if (previous.activityKind !== "tool.updated" && previous.activityKind !== "tool.completed") { + if ( + previous.sourceActivityKind !== "tool.updated" && + previous.sourceActivityKind !== "tool.completed" + ) { + return false; + } + if (next.sourceActivityKind !== "tool.updated" && next.sourceActivityKind !== "tool.completed") { return false; } - if (next.activityKind !== "tool.updated" && next.activityKind !== "tool.completed") { + if (previous.turnId !== next.turnId) { return false; } - if (previous.activityKind === "tool.completed") { + if (previous.sourceActivityKind === "tool.completed") { return false; } - return previous.collapseKey !== undefined && previous.collapseKey === next.collapseKey; + if (previous.collapseKey !== undefined && previous.collapseKey === next.collapseKey) { + return true; + } + return ( + previous.toolCallId !== undefined && + next.toolCallId === undefined && + previous.itemType === next.itemType && + normalizeCompactToolLabel(previous.toolTitle ?? previous.label) === + normalizeCompactToolLabel(next.toolTitle ?? next.label) + ); } function mergeDerivedWorkLogEntries( @@ -515,10 +598,13 @@ function mergeDerivedWorkLogEntries( const requestKind = next.requestKind ?? previous.requestKind; const collapseKey = next.collapseKey ?? previous.collapseKey; const toolLifecycleStatus = next.toolLifecycleStatus ?? previous.toolLifecycleStatus; + const toolCallId = next.toolCallId ?? previous.toolCallId; const toolData = next.toolData ?? previous.toolData; return { ...previous, ...next, + id: previous.id, + createdAt: previous.createdAt, ...(detail ? { detail } : {}), ...(command ? { command } : {}), ...(rawCommand ? { rawCommand } : {}), @@ -528,6 +614,7 @@ function mergeDerivedWorkLogEntries( ...(requestKind ? { requestKind } : {}), ...(collapseKey ? { collapseKey } : {}), ...(toolLifecycleStatus ? { toolLifecycleStatus } : {}), + ...(toolCallId ? { toolCallId } : {}), ...(toolData !== undefined ? { toolData } : {}), }; } @@ -544,9 +631,15 @@ function mergeChangedFiles( } function deriveToolLifecycleCollapseKey(entry: DerivedWorkLogEntry): string | undefined { - if (entry.activityKind !== "tool.updated" && entry.activityKind !== "tool.completed") { + if ( + entry.sourceActivityKind !== "tool.updated" && + entry.sourceActivityKind !== "tool.completed" + ) { return undefined; } + if (entry.toolCallId) { + return `tool:${entry.turnId ?? "no-turn"}:${entry.toolCallId}`; + } const normalizedLabel = normalizeCompactToolLabel(entry.toolTitle ?? entry.label); const detail = entry.detail?.trim() ?? ""; const itemType = entry.itemType ?? ""; @@ -556,10 +649,6 @@ function deriveToolLifecycleCollapseKey(entry: DerivedWorkLogEntry): string | un return [itemType, normalizedLabel, detail].join("\u001f"); } -function normalizeCompactToolLabel(value: string): string { - return value.replace(/\s+(?:complete|completed)\s*$/i, "").trim(); -} - function workLogEntryIsToolLike(entry: WorkLogEntry): boolean { if (entry.tone === "tool" || entry.tone === "thinking" || entry.tone === "error") { return true; @@ -636,12 +725,12 @@ function workEntryStatus(entry: WorkLogEntry): ThreadFeedActivity["status"] { function workEntryIcon(entry: DerivedWorkLogEntry): ThreadFeedActivity["icon"] { if ( - entry.activityKind === "user-input.requested" || - entry.activityKind === "user-input.resolved" + entry.sourceActivityKind === "user-input.requested" || + entry.sourceActivityKind === "user-input.resolved" ) { return "message"; } - if (entry.activityKind === "runtime.warning") return "warning"; + if (entry.sourceActivityKind === "runtime.warning") return "warning"; if (entry.requestKind === "command") return "command"; if (entry.requestKind === "file-read") return "eye"; if (entry.requestKind === "file-change") return "edit"; @@ -1275,10 +1364,14 @@ export function deriveThreadFeedPresentation( activeWorkStartedAt: string | null = null, ): ThreadFeedEntry[] { const sourceFeed = feed.filter( - (entry) => - entry.type !== "turn-fold" && entry.type !== "work-toggle" && entry.type !== "working", + (entry) => entry.type !== "turn-fold" && entry.type !== "work-toggle", + ); + const activeTailGroup = sourceFeed.findLast( + (entry) => entry.type !== "message" || !isEmptyMessage(entry), ); const foldsByAnchorId = deriveThreadFeedTurnFolds(sourceFeed, latestTurn); + const unsettledTurnId = deriveUnsettledTurnId(latestTurn); + const isWorking = activeWorkStartedAt !== null; const collapsedEntryIds = new Set(); for (const fold of foldsByAnchorId.values()) { if (!expandedTurnIds.has(fold.turnId)) { @@ -1290,6 +1383,13 @@ export function deriveThreadFeedPresentation( const result: ThreadFeedEntry[] = []; for (const entry of sourceFeed) { + const isActiveTailGroup = + isWorking && + unsettledTurnId !== null && + entry.type === "activity-group" && + activeTailGroup?.type === "activity-group" && + activeTailGroup.id === entry.id && + entry.turnId === unsettledTurnId; const fold = foldsByAnchorId.get(entry.id); if (fold) { result.push({ @@ -1302,49 +1402,65 @@ export function deriveThreadFeedPresentation( }); } if (!collapsedEntryIds.has(entry.id)) { - appendPresentedFeedEntry(result, entry, expandedWorkGroupIds); + appendPresentedFeedEntry( + result, + entry, + expandedWorkGroupIds, + unsettledTurnId, + isWorking, + isActiveTailGroup, + ); } } - if (activeWorkStartedAt !== null) { - result.push({ - type: "working", - id: "working-indicator-row", - createdAt: activeWorkStartedAt, - }); - } return result; } function appendPresentedFeedEntry( result: ThreadFeedEntry[], - entry: Exclude, + entry: Exclude, expandedWorkGroupIds: ReadonlySet, + unsettledTurnId: TurnId | null, + isWorking: boolean, + activeTail: boolean, ): void { if (entry.type !== "activity-group") { result.push(entry); return; } - const activities = entry.activities.filter( - (activity) => !(activity.toolLike && activity.status === "neutral"), + const activities = omitSupersededLifecycleMarkers( + entry.activities.filter( + (activity) => + !(activity.toolLike && activity.status === "neutral") || + (isWorking && + activity.lifecycleStatus === "inProgress" && + activity.turnId === unsettledTurnId), + ), + (activity) => activity.workEntry, ); if (activities.length === 0) { return; } - if (activities.length <= MAX_VISIBLE_WORK_LOG_ENTRIES) { - result.push({ - ...entry, - activities, - }); - return; - } - - const groupId = entry.id; - const expanded = expandedWorkGroupIds.has(groupId); - const hiddenCount = activities.length - MAX_VISIBLE_WORK_LOG_ENTRIES; - const visibleActivities = expanded ? activities : activities.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES); - - for (const activity of visibleActivities) { + let groupableRun: ThreadFeedActivity[] = []; + const flushGroupableRun = (isTrailingRun: boolean) => { + if (groupableRun.length === 0) return; + appendToolGroupRows( + result, + entry, + groupableRun, + expandedWorkGroupIds, + unsettledTurnId, + isWorking, + activeTail && isTrailingRun, + ); + groupableRun = []; + }; + for (const activity of activities) { + if (activity.workEntry.tone !== "error" && activity.workEntry.agentSpawn !== true) { + groupableRun.push(activity); + continue; + } + flushGroupableRun(false); result.push({ type: "activity-group", id: activity.id, @@ -1353,16 +1469,85 @@ function appendPresentedFeedEntry( activities: [activity], }); } + flushGroupableRun(true); +} + +function appendToolGroupRows( + result: ThreadFeedEntry[], + sourceGroup: Extract, + activities: ReadonlyArray, + expandedWorkGroupIds: ReadonlySet, + unsettledTurnId: TurnId | null, + isWorking: boolean, + activeTail: boolean, +): void { + const firstEntry = activities[0]!.workEntry; + const identity = firstEntry.toolCallId + ? `tool:${firstEntry.turnId ?? "no-turn"}:${firstEntry.toolCallId}` + : activities[0]!.id; + const groupId = `work-group:${identity}`; + const expanded = expandedWorkGroupIds.has(groupId); + const latestInProgressActivity = activities.findLast( + (activity) => + isWorking && activity.lifecycleStatus === "inProgress" && activity.turnId === unsettledTurnId, + ); + const live = activeTail || latestInProgressActivity !== undefined; + const latestActivity = activeTail + ? activities.at(-1)! + : (latestInProgressActivity ?? activities.at(-1)!); + const summary = live + ? liveToolActivitySummary(latestActivity) + : activities.length === 1 && !activities[0]!.toolLike + ? activities[0]!.workEntry.label + : summarizeToolGroup(activities.map((activity) => activity.workEntry)); result.push({ type: "work-toggle", - id: `work-toggle:${groupId}`, - createdAt: entry.createdAt, - turnId: entry.turnId, + id: `${live ? "work-live" : "work-toggle"}:${groupId}`, + createdAt: sourceGroup.createdAt, + turnId: sourceGroup.turnId, groupId, - hiddenCount, + hiddenCount: activities.length, expanded, - onlyToolActivities: activities.every((activity) => activity.toolLike), + summary, + summaryKind: toolGroupSummaryKind( + (live ? [latestActivity] : activities).map((activity) => activity.workEntry), + ), + hasFailure: activities.findLast((activity) => activity.toolLike)?.status === "failure", + live, + // Match the live label until the turn or contiguous tool run settles. + shimmer: live, }); + if (!expanded) { + return; + } + for (const activity of activities) { + result.push({ + type: "activity-group", + id: activity.id, + createdAt: activity.createdAt, + turnId: activity.turnId, + activities: [ + { + ...activity, + groupedToolDetail: true, + live: + isWorking && + activity.id === latestActivity.id && + activity.lifecycleStatus === "inProgress" && + activity.turnId === unsettledTurnId, + }, + ], + }); + } +} + +function liveToolActivitySummary(activity: ThreadFeedActivity): string { + const command = activity.workEntry.command?.trim(); + if (command) { + const program = commandProgramName(command); + return program ? `Running ${program}` : "Running command"; + } + return activity.detail ?? activity.summary; } /** @@ -1597,6 +1782,8 @@ export function buildThreadFeed( icon: workEntryIcon(entry), toolLike: workLogEntryIsToolLike(entry), status: workEntryStatus(entry), + ...(entry.toolLifecycleStatus ? { lifecycleStatus: entry.toolLifecycleStatus } : {}), + workEntry: entry, }, }; }), diff --git a/apps/mobile/src/lib/typography.test.ts b/apps/mobile/src/lib/typography.test.ts deleted file mode 100644 index 5b62e9bd3127..000000000000 --- a/apps/mobile/src/lib/typography.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { MOBILE_CODE_SURFACE, MOBILE_TYPOGRAPHY } from "./typography"; - -describe("mobile typography", () => { - it("uses the intentional mobile font scale anchored at a 16pt body", () => { - expect(Object.values(MOBILE_TYPOGRAPHY).map(({ fontSize }) => fontSize)).toEqual([ - 11, 12, 13, 14, 16, 18, 21, 26, 30, - ]); - expect(MOBILE_TYPOGRAPHY.body).toEqual({ fontSize: 16, lineHeight: 23 }); - }); - - it("uses caption-sized code with a compact readable row height", () => { - expect(MOBILE_CODE_SURFACE).toMatchObject({ - fontSize: MOBILE_TYPOGRAPHY.caption.fontSize, - lineNumberFontSize: MOBILE_TYPOGRAPHY.micro.fontSize, - rowHeight: 22, - }); - }); -}); diff --git a/apps/mobile/src/lib/uniwind-dev-refresh.test.ts b/apps/mobile/src/lib/uniwind-dev-refresh.test.ts new file mode 100644 index 000000000000..852bf1f04d9f --- /dev/null +++ b/apps/mobile/src/lib/uniwind-dev-refresh.test.ts @@ -0,0 +1,97 @@ +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import * as NodeURL from "node:url"; + +vi.mock("react-native", () => ({ + Appearance: { + addChangeListener: vi.fn(), + getColorScheme: () => "light", + setColorScheme: vi.fn(), + }, + Platform: { constants: {}, OS: "ios" }, +})); + +vi.mock("../../node_modules/uniwind/src/core/listener", () => ({ + UniwindListener: { notify() {}, notifyAll() {} }, +})); + +vi.mock("../../node_modules/uniwind/src/core/native", () => ({ + UniwindStore: { + reinit: (generateStyleSheetCallback: () => unknown) => { + generateStyleSheetCallback(); + }, + runtime: { currentThemeName: "light", insets: {} }, + vars: {}, + }, +})); + +const loadUniwind = async () => { + const modulePath = NodeURL.fileURLToPath( + new URL("../../node_modules/uniwind/src/core/config/config.native.ts", import.meta.url), + ); + const { Uniwind } = (await import(/* @vite-ignore */ modulePath)) as { + Uniwind: { readonly themes: Array }; + }; + return Uniwind as typeof Uniwind & { + __reinit: (initialize: () => unknown, themes: Array, fingerprint?: string) => void; + }; +}; + +describe("Uniwind native stylesheet refresh", () => { + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + vi.stubGlobal("__DEV__", true); + }); + + it("initializes once for identical generated styles", async () => { + const Uniwind = await loadUniwind(); + const initialize = vi.fn(() => ({})); + + Uniwind.__reinit(initialize, ["light", "dark"], "same-output"); + Uniwind.__reinit(initialize, ["light", "dark"], "same-output"); + + expect(initialize).toHaveBeenCalledTimes(1); + }); + + it("reinitializes for changed generated styles and themes", async () => { + const Uniwind = await loadUniwind(); + const initialize = vi.fn(() => ({})); + + Uniwind.__reinit(initialize, ["light", "dark"], "before"); + Uniwind.__reinit(initialize, ["light", "dark"], "after"); + Uniwind.__reinit(initialize, ["light", "dark", "dim"], "themes-with-dim"); + + expect(initialize).toHaveBeenCalledTimes(3); + expect(Uniwind.themes).toEqual(["light", "dark", "dim"]); + }); + + it("retries the same output after initialization fails", async () => { + const Uniwind = await loadUniwind(); + const initialize = vi + .fn() + .mockImplementationOnce(() => { + throw new Error("initialization failed"); + }) + .mockImplementationOnce(() => ({})); + + expect(() => Uniwind.__reinit(initialize, ["light", "dark"], "retry-output")).toThrow( + "initialization failed", + ); + Uniwind.__reinit(initialize, ["light", "dark"], "retry-output"); + + expect(initialize).toHaveBeenCalledTimes(2); + }); + + it("keeps no-fingerprint and production reinitialization semantics", async () => { + const Uniwind = await loadUniwind(); + const initialize = vi.fn(() => ({})); + + Uniwind.__reinit(initialize, ["light", "dark"]); + Uniwind.__reinit(initialize, ["light", "dark"]); + vi.stubGlobal("__DEV__", false); + Uniwind.__reinit(initialize, ["light", "dark"], "same-output"); + Uniwind.__reinit(initialize, ["light", "dark"], "same-output"); + + expect(initialize).toHaveBeenCalledTimes(4); + }); +}); diff --git a/apps/mobile/src/lib/useFontFamily.ts b/apps/mobile/src/lib/useFontFamily.ts index 09805ae11546..4f845753be52 100644 --- a/apps/mobile/src/lib/useFontFamily.ts +++ b/apps/mobile/src/lib/useFontFamily.ts @@ -1,15 +1,13 @@ -import { useCSSVariable } from "uniwind"; - -const FONT_FAMILY_VARIABLES = { - regular: "--font-sans", - medium: "--font-medium", - bold: "--font-bold", +const FONT_FAMILIES = { + regular: "DMSans-Regular", + medium: "DMSans-Medium", + bold: "DMSans-Bold", } as const; /** * Resolves a font family for APIs that require a style object or native prop. * Prefer Uniwind font classes when the target component accepts `className`. */ -export function useFontFamily(weight: keyof typeof FONT_FAMILY_VARIABLES): string { - return useCSSVariable(FONT_FAMILY_VARIABLES[weight]) as string; +export function useFontFamily(weight: keyof typeof FONT_FAMILIES): string { + return FONT_FAMILIES[weight]; } diff --git a/apps/mobile/src/lib/useMobileNavigationTheme.ts b/apps/mobile/src/lib/useMobileNavigationTheme.ts index 6711f72c7435..7b7a1fdf3cf6 100644 --- a/apps/mobile/src/lib/useMobileNavigationTheme.ts +++ b/apps/mobile/src/lib/useMobileNavigationTheme.ts @@ -1,22 +1,31 @@ import { DarkTheme, DefaultTheme, type Theme } from "@react-navigation/native"; import { useMemo } from "react"; -import type { MobileThemeAppearance } from "./mobileTheme"; -import { useThemeColor } from "./useThemeColor"; - -export function useMobileNavigationTheme(appearance: MobileThemeAppearance): Theme { - const primary = String(useThemeColor("--color-primary")); - const background = String(useThemeColor("--color-screen")); - const card = String(useThemeColor("--color-sheet-solid")); - const text = String(useThemeColor("--color-foreground")); - const border = String(useThemeColor("--color-header-border")); - const notification = String(useThemeColor("--color-danger-foreground")); +import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; +import { useUniwindTheme } from "./useUniwindTheme"; +/** + * React Navigation requires a JS theme object. Derive it from the same palette + * source as Uniwind instead of subscribing the app root to CSS variables. The + * preferences provider applies the registered Uniwind theme first, then + * publishes this matching navigation palette through React. + */ +export function useMobileNavigationTheme(): Theme { + const { themeAppearance: appearance } = useAppearancePreferences(); + const variables = useUniwindTheme(); return useMemo(() => { const base = appearance === "dark" ? DarkTheme : DefaultTheme; return { ...base, - colors: { ...base.colors, primary, background, card, text, border, notification }, + colors: { + ...base.colors, + primary: variables["--color-primary"], + background: variables["--color-screen"], + card: variables["--color-sheet-solid"], + text: variables["--color-foreground"], + border: variables["--color-header-border"], + notification: variables["--color-danger-foreground"], + }, }; - }, [appearance, background, border, card, notification, primary, text]); + }, [appearance, variables]); } diff --git a/apps/mobile/src/lib/useThemeColor.ts b/apps/mobile/src/lib/useThemeColor.ts deleted file mode 100644 index 38dbf6c9b087..000000000000 --- a/apps/mobile/src/lib/useThemeColor.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { ColorValue } from "react-native"; -import { useCSSVariable } from "uniwind"; - -/** - * Typed wrapper around `useCSSVariable` that returns a `ColorValue` for use - * in React Native style props (backgroundColor, tintColor, etc.). - * - * Usage: `const color = useThemeColor("--color-icon");` - */ -export function useThemeColor(variable: `--color-${string}`): ColorValue { - return useCSSVariable(variable) as string as ColorValue; -} diff --git a/apps/mobile/src/lib/useUniwindTheme.ts b/apps/mobile/src/lib/useUniwindTheme.ts new file mode 100644 index 000000000000..06c50c859ae9 --- /dev/null +++ b/apps/mobile/src/lib/useUniwindTheme.ts @@ -0,0 +1,21 @@ +import { useMemo } from "react"; + +import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; +import type { MobileThemeVariables } from "./mobileTheme"; +import { getMobileThemeRuntimeVariables } from "./mobileThemeVariables"; + +/** + * Complete JS palette for native and third-party APIs that cannot consume a + * Uniwind className (React Navigation, native editors, Markdown, SVG gradients, + * Reanimated worklets). Ordinary React Native rendering must use className. + * + * This bridge follows the same single React theme commit as the root + * ScopedTheme instead of subscribing every consumer to CSS-variable updates. + */ +export function useUniwindTheme(): MobileThemeVariables { + const { themeAppearance, themeId } = useAppearancePreferences(); + return useMemo( + () => getMobileThemeRuntimeVariables(themeId, themeAppearance), + [themeAppearance, themeId], + ); +} diff --git a/apps/mobile/src/lib/videoPreviewSource.ts b/apps/mobile/src/lib/videoPreviewSource.ts new file mode 100644 index 000000000000..af87a8d0f73a --- /dev/null +++ b/apps/mobile/src/lib/videoPreviewSource.ts @@ -0,0 +1,54 @@ +import type { AssetResource, ChatFileAttachment, EnvironmentId } from "@t3tools/contracts"; + +import type { DraftComposerFileAttachment } from "./composerImages"; +import type { MediaActionsSource } from "./mediaActions"; + +export type MediaVideoPreviewSource = { + readonly type: "media"; + readonly name: string; + readonly mimeType: string; + readonly sourceIdentifier?: string; + readonly srcFragment?: string; + readonly actionsSource?: MediaActionsSource; +} & ( + | { readonly uri: string } + | { + readonly environmentId: EnvironmentId; + readonly resource: Extract; + } +); + +/** Resolves the current capability without making it the identity of the video. */ +export function mediaVideoPreviewUri( + source: MediaVideoPreviewSource, + assetUrl: string | null, +): string | null { + if ("uri" in source) return source.uri; + return assetUrl === null ? null : assetUrl + (source.srcFragment ?? ""); +} + +/** Keeps thumbnails independent of refreshed asset signatures and scoped to their environment. */ +export function mediaVideoThumbnailKey(source: MediaVideoPreviewSource): string { + return JSON.stringify( + "uri" in source + ? ["media-video", source.uri] + : [ + "media-video", + source.environmentId, + source.resource.threadId, + source.resource.path, + source.srcFragment ?? "", + ], + ); +} + +export type AttachmentVideoPreviewSource = ( + | { readonly type: "local"; readonly attachment: DraftComposerFileAttachment } + | { + readonly type: "remote"; + readonly environmentId: EnvironmentId; + readonly attachment: ChatFileAttachment; + } +) & { readonly sourceIdentifier?: string }; + +export type VideoPreviewSource = AttachmentVideoPreviewSource | MediaVideoPreviewSource; diff --git a/apps/mobile/src/lib/videoThumbnails.test.ts b/apps/mobile/src/lib/videoThumbnails.test.ts new file mode 100644 index 000000000000..e577e448ea2a --- /dev/null +++ b/apps/mobile/src/lib/videoThumbnails.test.ts @@ -0,0 +1,168 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const mocks = vi.hoisted(() => ({ createPlayer: vi.fn() })); +vi.mock("expo-video", () => ({ createVideoPlayer: mocks.createPlayer })); + +let thumbnails: typeof import("./videoThumbnails"); +const frame = { width: 480, height: 270 }; +const player = () => ({ + replaceAsync: vi.fn(async (): Promise => {}), + generateThumbnailsAsync: vi.fn(async () => [frame]), + release: vi.fn(), +}); +const source = () => ({ uri: "file:///clip.mp4", dispose: vi.fn() }); + +beforeEach(async () => { + vi.resetModules(); + mocks.createPlayer.mockReset().mockImplementation(player); + thumbnails = await import("./videoThumbnails"); +}); + +afterEach(() => vi.useRealTimers()); + +describe("video thumbnails", () => { + it("reuses a frame for duplicate requests and refreshed signed URLs", async () => { + const file = source(); + const resolveSource = vi.fn(async () => file); + const signal = new AbortController().signal; + const results = await Promise.all([ + thumbnails.loadVideoThumbnail("env:clip", resolveSource, signal), + thumbnails.loadVideoThumbnail("env:clip", resolveSource, signal), + ]); + expect(results).toEqual([frame, frame]); + expect(resolveSource).toHaveBeenCalledTimes(1); + expect(mocks.createPlayer).toHaveBeenCalledTimes(1); + expect(file.dispose).toHaveBeenCalledTimes(1); + const refreshed = vi.fn(async () => ({ ...source(), uri: "https://host/new-token/clip.mp4" })); + expect(await thumbnails.loadVideoThumbnail("env:clip", refreshed, signal)).toBe(frame); + expect(refreshed).not.toHaveBeenCalled(); + }); + + it("serializes decoding and skips queued requests that scroll out of view", async () => { + const started = Promise.withResolvers(); + const generated = Promise.withResolvers<(typeof frame)[]>(); + const first = player(); + first.generateThumbnailsAsync.mockImplementation(() => { + started.resolve(); + return generated.promise; + }); + mocks.createPlayer.mockReturnValueOnce(first); + const firstRequest = thumbnails.loadVideoThumbnail( + "first", + async () => source(), + new AbortController().signal, + ); + await started.promise; + const removed = new AbortController(); + const skipped = vi.fn(async () => source()); + const queued = thumbnails.loadVideoThumbnail("removed", skipped, removed.signal); + const next = vi.fn(async () => source()); + const nextRequest = thumbnails.loadVideoThumbnail("next", next, new AbortController().signal); + expect(next).not.toHaveBeenCalled(); + removed.abort(); + generated.resolve([frame]); + expect(await firstRequest).toBe(frame); + expect(await queued).toBeNull(); + expect(await nextRequest).toBe(frame); + expect(skipped).not.toHaveBeenCalled(); + expect(first.release).toHaveBeenCalledTimes(1); + }); + + it("releases an active canceled player and ignores late source loading", async () => { + const started = Promise.withResolvers(); + const replaced = Promise.withResolvers(); + const first = player(); + first.replaceAsync.mockImplementation(() => { + started.resolve(); + return replaced.promise; + }); + mocks.createPlayer.mockReturnValueOnce(first); + const file = source(); + const controller = new AbortController(); + const request = thumbnails.loadVideoThumbnail("canceled", async () => file, controller.signal); + await started.promise; + controller.abort(); + expect(await request).toBeNull(); + expect(first.release).toHaveBeenCalledTimes(1); + expect(file.dispose).toHaveBeenCalledTimes(1); + replaced.resolve(); + expect( + await thumbnails.loadVideoThumbnail( + "next", + async () => source(), + new AbortController().signal, + ), + ).toBe(frame); + expect(first.generateThumbnailsAsync).not.toHaveBeenCalled(); + expect(thumbnails.cachedVideoThumbnail("canceled")).toBeNull(); + }); + + it("releases failed extractions and permits a later retry", async () => { + const broken = player(); + broken.generateThumbnailsAsync.mockRejectedValue(new Error("Invalid video")); + mocks.createPlayer.mockReturnValueOnce(broken); + const file = source(); + expect( + await thumbnails.loadVideoThumbnail("retry", async () => file, new AbortController().signal), + ).toBeNull(); + expect(broken.release).toHaveBeenCalledTimes(1); + expect(file.dispose).toHaveBeenCalledTimes(1); + expect( + await thumbnails.loadVideoThumbnail( + "retry", + async () => source(), + new AbortController().signal, + ), + ).toBe(frame); + }); + + it("does not let an unreachable source block the queue indefinitely", async () => { + vi.useFakeTimers(); + const started = Promise.withResolvers(); + const first = player(); + first.replaceAsync.mockImplementation(() => { + started.resolve(); + return new Promise(() => {}); + }); + mocks.createPlayer.mockReturnValueOnce(first); + const file = source(); + const request = thumbnails.loadVideoThumbnail( + "unreachable", + async () => file, + new AbortController().signal, + ); + await started.promise; + await vi.advanceTimersByTimeAsync(15_000); + expect(await request).toBeNull(); + expect(first.release).toHaveBeenCalledTimes(1); + expect(file.dispose).toHaveBeenCalledTimes(1); + expect( + await thumbnails.loadVideoThumbnail( + "reachable", + async () => source(), + new AbortController().signal, + ), + ).toBe(frame); + }); + + it("bounds the retained native images without invalidating frames still displayed", async () => { + for (let i = 0; i < 33; i++) { + await thumbnails.loadVideoThumbnail( + `clip:${i}`, + async () => source(), + new AbortController().signal, + ); + } + expect(thumbnails.cachedVideoThumbnail("clip:0")).toBeNull(); + expect(thumbnails.cachedVideoThumbnail("clip:32")).toBe(frame); + expect(mocks.createPlayer).toHaveBeenCalledTimes(33); + expect( + await thumbnails.loadVideoThumbnail( + "clip:0", + async () => source(), + new AbortController().signal, + ), + ).toBe(frame); + expect(mocks.createPlayer).toHaveBeenCalledTimes(34); + }); +}); diff --git a/apps/mobile/src/lib/videoThumbnails.ts b/apps/mobile/src/lib/videoThumbnails.ts new file mode 100644 index 000000000000..927e1174a7f2 --- /dev/null +++ b/apps/mobile/src/lib/videoThumbnails.ts @@ -0,0 +1,81 @@ +import type { VideoThumbnail } from "expo-video"; + +import type { AttachmentPreviewFile } from "./attachmentDownload"; + +const thumbnails = new Map(); +const MAX_CACHED_THUMBNAILS = 32; +let pending: Promise = Promise.resolve(); + +export function cachedVideoThumbnail(key: string): VideoThumbnail | null { + return thumbnails.get(key) ?? null; +} + +async function extractFrame(uri: string, signal: AbortSignal) { + const { createVideoPlayer } = await import("expo-video"); + if (signal.aborted) return null; + const player = createVideoPlayer(null); + let disposed = false; + let cancel = () => {}; + let timeout: ReturnType | undefined; + try { + // Never play or change audio settings: thumbnails must leave the shared audio session alone. + player.bufferOptions = { preferredForwardBufferDuration: 1 }; + const canceled = new Promise((resolve) => { + cancel = () => resolve(null); + }); + signal.addEventListener("abort", cancel, { once: true }); + // An unreachable environment must not hold up thumbnails for other environments. + timeout = setTimeout(cancel, 15_000); + const frame = (async () => { + await player.replaceAsync({ uri, contentType: "progressive" }); + if (disposed || signal.aborted) return null; + const [thumbnail] = await player.generateThumbnailsAsync([0], { + maxWidth: 480, + maxHeight: 480, + }); + return thumbnail ?? null; + })(); + return await Promise.race([frame, canceled]); + } finally { + disposed = true; + clearTimeout(timeout); + signal.removeEventListener("abort", cancel); + player.release(); + } +} + +/** Serializes frame extraction and releases each temporary player and local-file lease. */ +export function loadVideoThumbnail( + key: string, + resolveSource: ( + signal: AbortSignal, + ) => Promise | null>, + signal: AbortSignal, +): Promise { + if (signal.aborted) return Promise.resolve(null); + const cached = cachedVideoThumbnail(key); + if (cached) return Promise.resolve(cached); + const load = pending + .then(async () => { + if (signal.aborted) return null; + const cached = cachedVideoThumbnail(key); + if (cached) return cached; + + const source = await resolveSource(signal); + if (!source) return null; + try { + const thumbnail = await extractFrame(source.uri, signal); + if (!thumbnail || signal.aborted) return null; + thumbnails.set(key, thumbnail); + if (thumbnails.size > MAX_CACHED_THUMBNAILS) { + thumbnails.delete(thumbnails.keys().next().value!); + } + return thumbnail; + } finally { + source.dispose(); + } + }) + .catch(() => null); + pending = load; + return load; +} diff --git a/apps/mobile/src/native/T3ComposerEditor.ios.tsx b/apps/mobile/src/native/T3ComposerEditor.ios.tsx index 32094109b1f3..85decebe9ed0 100644 --- a/apps/mobile/src/native/T3ComposerEditor.ios.tsx +++ b/apps/mobile/src/native/T3ComposerEditor.ios.tsx @@ -14,7 +14,7 @@ import { Image, StyleSheet } from "react-native"; import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons"; import { resolveMarkdownFileIcon } from "@t3tools/mobile-markdown-text/links"; -import { useThemeColor } from "../lib/useThemeColor"; +import { useUniwindTheme } from "../lib/useUniwindTheme"; import { useFontFamily } from "../lib/useFontFamily"; import { useScaledTextRole } from "../features/settings/appearance/useScaledTextRole"; import { @@ -62,6 +62,7 @@ interface NativeComposerEditorProps extends ViewProps { readonly lineHeight: number; readonly contentInsetVertical: number; readonly editable: boolean; + readonly readOnly: boolean; readonly scrollEnabled: boolean; readonly autoFocus: boolean; readonly autoCorrect: boolean; @@ -110,15 +111,7 @@ export function ComposerEditor({ const nativeEventSnapshotsRef = useRef([]); const confirmedTokensRef = useRef(collectComposerInlineTokens(props.value)); const bodyText = useScaledTextRole("body"); - const textColor = useThemeColor("--color-foreground"); - const placeholderColor = useThemeColor("--color-placeholder"); - const chipBackground = useThemeColor("--color-subtle"); - const chipBorder = useThemeColor("--color-border"); - const chipText = useThemeColor("--color-foreground"); - const skillBackground = useThemeColor("--color-inline-skill-background"); - const skillBorder = useThemeColor("--color-inline-skill-border"); - const skillText = useThemeColor("--color-inline-skill-foreground"); - const fileTint = useThemeColor("--color-icon-muted"); + const theme = useUniwindTheme(); const fontFamily = useFontFamily("regular"); useImperativeHandle( @@ -219,15 +212,15 @@ export function ComposerEditor({ [], ); const themeJson = JSON.stringify({ - text: String(textColor), - placeholder: String(placeholderColor), - chipBackground: String(chipBackground), - chipBorder: String(chipBorder), - chipText: String(chipText), - skillBackground: String(skillBackground), - skillBorder: String(skillBorder), - skillText: String(skillText), - fileTint: String(fileTint), + text: theme["--color-foreground"], + placeholder: theme["--color-placeholder"], + chipBackground: theme["--color-subtle"], + chipBorder: theme["--color-border"], + chipText: theme["--color-foreground"], + skillBackground: theme["--color-inline-skill-background"], + skillBorder: theme["--color-inline-skill-border"], + skillText: theme["--color-inline-skill-foreground"], + fileTint: theme["--color-icon-muted"], }); const resolvedTextStyle = StyleSheet.flatten(textStyle) ?? {}; return ( @@ -251,6 +244,7 @@ export function ComposerEditor({ } contentInsetVertical={contentInsetVertical} editable={props.editable ?? true} + readOnly={props.readOnly ?? false} scrollEnabled={props.scrollEnabled ?? true} autoFocus={props.autoFocus ?? false} autoCorrect={props.autoCorrect ?? true} diff --git a/apps/mobile/src/native/T3ComposerEditor.native.tsx b/apps/mobile/src/native/T3ComposerEditor.native.tsx index ff177abf1642..1a488d34f084 100644 --- a/apps/mobile/src/native/T3ComposerEditor.native.tsx +++ b/apps/mobile/src/native/T3ComposerEditor.native.tsx @@ -18,7 +18,7 @@ import { resolveMarkdownFileIcon } from "@t3tools/mobile-markdown-text/links"; import { MOBILE_TYPOGRAPHY } from "../lib/typography"; import { useNativePaste } from "../lib/useNativePaste"; import { useFontFamily } from "../lib/useFontFamily"; -import { useThemeColor } from "../lib/useThemeColor"; +import { useUniwindTheme } from "../lib/useUniwindTheme"; import { acknowledgeComposerNativeEvent, assumeComposerControlledState, @@ -111,15 +111,7 @@ export function ComposerEditor({ const nativeEventSnapshotsRef = useRef([]); const [initialConfirmedTokens] = useState(() => collectComposerInlineTokens(props.value)); const confirmedTokensRef = useRef(initialConfirmedTokens); - const textColor = useThemeColor("--color-foreground"); - const placeholderColor = useThemeColor("--color-placeholder"); - const chipBackground = useThemeColor("--color-subtle"); - const chipBorder = useThemeColor("--color-border"); - const chipText = useThemeColor("--color-foreground"); - const skillBackground = useThemeColor("--color-inline-skill-background"); - const skillBorder = useThemeColor("--color-inline-skill-border"); - const skillText = useThemeColor("--color-inline-skill-foreground"); - const fileTint = useThemeColor("--color-icon-muted"); + const theme = useUniwindTheme(); const handlePaste = useNativePaste((uris) => onPasteImages?.(uris)); useImperativeHandle( @@ -220,15 +212,15 @@ export function ComposerEditor({ [], ); const themeJson = JSON.stringify({ - text: String(textColor), - placeholder: String(placeholderColor), - chipBackground: String(chipBackground), - chipBorder: String(chipBorder), - chipText: String(chipText), - skillBackground: String(skillBackground), - skillBorder: String(skillBorder), - skillText: String(skillText), - fileTint: String(fileTint), + text: theme["--color-foreground"], + placeholder: theme["--color-placeholder"], + chipBackground: theme["--color-subtle"], + chipBorder: theme["--color-border"], + chipText: theme["--color-foreground"], + skillBackground: theme["--color-inline-skill-background"], + skillBorder: theme["--color-inline-skill-border"], + skillText: theme["--color-inline-skill-foreground"], + fileTint: theme["--color-icon-muted"], }); const resolvedTextStyle = StyleSheet.flatten(textStyle) ?? {}; const regularFontFamily = useFontFamily("regular"); @@ -256,7 +248,7 @@ export function ComposerEditor({ } contentInsetVertical={contentInsetVertical} singleLineCentered={props.singleLineCentered ?? false} - editable={props.editable ?? true} + editable={(props.editable ?? true) && !(props.readOnly ?? false)} scrollEnabled={props.scrollEnabled ?? true} autoFocus={props.autoFocus ?? false} autoCorrect={props.autoCorrect ?? true} diff --git a/apps/mobile/src/native/T3ComposerEditor.tsx b/apps/mobile/src/native/T3ComposerEditor.tsx index e082d3892ad9..07a409c9a48f 100644 --- a/apps/mobile/src/native/T3ComposerEditor.tsx +++ b/apps/mobile/src/native/T3ComposerEditor.tsx @@ -2,7 +2,6 @@ import { TextInputWrapper } from "expo-paste-input"; import { useImperativeHandle, useRef } from "react"; import { TextInput, type TextInput as RNTextInput } from "react-native"; -import { useThemeColor } from "../lib/useThemeColor"; import { useFontFamily } from "../lib/useFontFamily"; import { useScaledTextRole } from "../features/settings/appearance/useScaledTextRole"; import { useNativePaste } from "../lib/useNativePaste"; @@ -17,12 +16,11 @@ export function ComposerEditor({ textStyle, contentInsetVertical = 0, singleLineCentered: _singleLineCentered, + readOnly = false, ...props }: ComposerEditorProps) { const inputRef = useRef(null); const bodyText = useScaledTextRole("body"); - const foregroundColor = useThemeColor("--color-foreground"); - const placeholderColor = useThemeColor("--color-placeholder"); const fontFamily = useFontFamily("regular"); const handlePaste = useNativePaste((uris) => onPasteImages?.(uris)); @@ -42,15 +40,16 @@ export function ComposerEditor({ props.onSelectionChange?.(event.nativeEvent.selection)} multiline={props.multiline ?? true} - placeholderTextColor={placeholderColor} + placeholderTextColorClassName={"accent-placeholder"} + className="text-foreground" style={[ { flex: 1, minHeight: 0, - color: foregroundColor, fontFamily, ...bodyText, paddingVertical: contentInsetVertical, diff --git a/apps/mobile/src/native/T3ComposerEditor.types.ts b/apps/mobile/src/native/T3ComposerEditor.types.ts index bfc47ed367b5..c8833bb4cb61 100644 --- a/apps/mobile/src/native/T3ComposerEditor.types.ts +++ b/apps/mobile/src/native/T3ComposerEditor.types.ts @@ -23,6 +23,8 @@ export interface ComposerEditorProps { readonly placeholder?: string; readonly autoFocus?: boolean; readonly editable?: boolean; + /** Blocks user edits while preserving focus, selection, and the software keyboard on iOS. */ + readonly readOnly?: boolean; readonly scrollEnabled?: boolean; readonly autoCorrect?: boolean; readonly spellCheck?: boolean; diff --git a/apps/mobile/src/native/T3KeyboardCommands.android.tsx b/apps/mobile/src/native/T3KeyboardCommands.android.tsx new file mode 100644 index 000000000000..ff4e817c2002 --- /dev/null +++ b/apps/mobile/src/native/T3KeyboardCommands.android.tsx @@ -0,0 +1,31 @@ +import { requireNativeView } from "expo"; +import type { PropsWithChildren } from "react"; +import type { NativeSyntheticEvent, ViewProps } from "react-native"; + +import type { HardwareKeyboardCommand } from "../features/keyboard/hardwareKeyboardCommands"; + +interface NativeKeyboardCommandsProps extends ViewProps, PropsWithChildren { + readonly enabledCommands: ReadonlyArray; + readonly onCommand: ( + event: NativeSyntheticEvent<{ readonly command: HardwareKeyboardCommand }>, + ) => void; +} + +const NativeKeyboardCommands = requireNativeView("T3KeyboardCommands"); + +export function T3KeyboardCommands( + props: PropsWithChildren<{ + readonly enabledCommands: ReadonlyArray; + readonly onCommand: (command: HardwareKeyboardCommand) => void; + }>, +) { + return ( + props.onCommand(event.nativeEvent.command)} + enabledCommands={props.enabledCommands} + style={{ flex: 1 }} + > + {props.children} + + ); +} diff --git a/apps/mobile/src/native/voiceTranscription.ios.test.ts b/apps/mobile/src/native/voiceTranscription.ios.test.ts new file mode 100644 index 000000000000..b08e32cdb6f6 --- /dev/null +++ b/apps/mobile/src/native/voiceTranscription.ios.test.ts @@ -0,0 +1,140 @@ +import type { TranscriptionResult } from "@react-native-ai/apple/src/NativeAppleTranscription"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { VoiceTranscriptionError } from "@t3tools/client-runtime/voice-input"; + +const mocks = vi.hoisted(() => ({ + isAvailable: vi.fn<(locale: string) => boolean>(), + prepare: vi.fn<(locale: string) => Promise>(), + transcribe: vi.fn<(audio: ArrayBufferLike, locale: string) => Promise>(), + readAudio: vi.fn<() => Promise>(), +})); + +vi.mock("@react-native-ai/apple/src/NativeAppleTranscription", () => ({ + default: { + isAvailable: mocks.isAvailable, + prepare: mocks.prepare, + transcribe: mocks.transcribe, + }, +})); + +vi.mock("expo-file-system", () => ({ + File: class { + arrayBuffer = mocks.readAudio; + }, +})); + +import { getLocalVoiceTranscriber } from "./voiceTranscription.ios"; + +const audio = new ArrayBuffer(4); +const nativeTranscript: TranscriptionResult = { + duration: 2, + segments: [ + { text: " Hej", startSecond: 0, endSecond: 1 }, + { text: "världen. ", startSecond: 1, endSecond: 2 }, + ], +}; + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((next) => { + resolve = next; + }); + return { promise, resolve }; +} + +beforeEach(() => { + vi.resetAllMocks(); + mocks.isAvailable.mockReturnValue(true); + mocks.prepare.mockResolvedValue("sv-SE"); + mocks.readAudio.mockResolvedValue(audio); + mocks.transcribe.mockResolvedValue(nativeTranscript); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("getLocalVoiceTranscriber", () => { + it("keeps the selected language and Apple's resolved locale when the device language changes", async () => { + const resolvedOptions = Intl.DateTimeFormat().resolvedOptions(); + const deviceLocale = vi + .spyOn(Intl.DateTimeFormat.prototype, "resolvedOptions") + .mockReturnValue({ ...resolvedOptions, locale: "sv-FI" }); + const transcriber = getLocalVoiceTranscriber()!; + const options = { signal: new AbortController().signal }; + + deviceLocale.mockReturnValue({ ...resolvedOptions, locale: "de-DE" }); + const prepared = await transcriber.prepare(options); + deviceLocale.mockReturnValue({ ...resolvedOptions, locale: "en-US" }); + + await expect(prepared.transcribe("file:///voice.m4a", options)).resolves.toBe("Hej världen."); + expect(mocks.prepare).toHaveBeenCalledWith("sv-FI"); + expect(prepared.locale).toBe("sv-SE"); + expect(mocks.transcribe).toHaveBeenCalledWith(audio, "sv-SE"); + }); + + it("does not start native transcription after cancellation during a file read", async () => { + const enteredRead = deferred(); + const readResult = deferred(); + mocks.readAudio.mockImplementation(() => { + enteredRead.resolve(); + return readResult.promise; + }); + const controller = new AbortController(); + const options = { signal: controller.signal }; + const prepared = await getLocalVoiceTranscriber()!.prepare(options); + const result = prepared + .transcribe("file:///voice.m4a", options) + .catch((error: unknown) => error); + + await enteredRead.promise; + controller.abort(); + readResult.resolve(audio); + + const error = await result; + expect(error).toBeInstanceOf(VoiceTranscriptionError); + expect(error).toMatchObject({ code: "cancelled" }); + expect(mocks.transcribe).not.toHaveBeenCalled(); + }); + + it.each(["prepare", "transcribe"] as const)( + "waits for native %s to finish before settling cancellation", + async (phase) => { + const enteredNative = deferred(); + const finishNative = deferred(); + if (phase === "prepare") { + mocks.prepare.mockImplementation(async () => { + enteredNative.resolve(); + await finishNative.promise; + return "sv-SE"; + }); + } else { + mocks.transcribe.mockImplementation(async () => { + enteredNative.resolve(); + await finishNative.promise; + return nativeTranscript; + }); + } + const controller = new AbortController(); + const options = { signal: controller.signal }; + const transcriber = getLocalVoiceTranscriber()!; + const operation = + phase === "prepare" + ? transcriber.prepare(options) + : (await transcriber.prepare(options)).transcribe("file:///voice.m4a", options); + const settled = vi.fn((value: unknown) => value); + const result = operation.then(settled, settled); + + await enteredNative.promise; + controller.abort(); + await new Promise((resolve) => setImmediate(resolve)); + expect(settled).not.toHaveBeenCalled(); + finishNative.resolve(); + + const error = await result; + expect(error).toBeInstanceOf(VoiceTranscriptionError); + expect(error).toMatchObject({ code: "cancelled" }); + }, + ); +}); diff --git a/apps/mobile/src/native/voiceTranscription.ios.ts b/apps/mobile/src/native/voiceTranscription.ios.ts new file mode 100644 index 000000000000..216b9e958dd6 --- /dev/null +++ b/apps/mobile/src/native/voiceTranscription.ios.ts @@ -0,0 +1,98 @@ +import AppleTranscription from "@react-native-ai/apple/src/NativeAppleTranscription"; +import { File } from "expo-file-system"; + +import { + VoiceTranscriptionError, + throwIfVoiceTranscriptionAborted, + type PreparedVoiceTranscription, + type VoiceTranscriber, + type VoiceTranscriptionOptions, +} from "@t3tools/client-runtime/voice-input"; + +function getDeviceLocale(): string { + return Intl.DateTimeFormat().resolvedOptions().locale; +} + +function wrapError( + code: "preparation-failed" | "transcription-failed", + message: string, + cause: unknown, +): VoiceTranscriptionError { + if (cause instanceof VoiceTranscriptionError) { + return cause; + } + + return new VoiceTranscriptionError(code, message, { cause }); +} + +function getNativeErrorCode(error: unknown): string | undefined { + if (typeof error !== "object" || error === null || !("code" in error)) { + return undefined; + } + + return typeof error.code === "string" ? error.code : undefined; +} + +export function getLocalVoiceTranscriber(): VoiceTranscriber | null { + const locale = getDeviceLocale(); + if (!AppleTranscription.isAvailable(locale)) return null; + return { prepare: (options) => prepareVoiceTranscription(locale, options) }; +} + +async function prepareVoiceTranscription( + locale: string, + { signal }: VoiceTranscriptionOptions, +): Promise { + throwIfVoiceTranscriptionAborted(signal); + if (!AppleTranscription.isAvailable(locale)) { + throw new VoiceTranscriptionError( + "unavailable", + "Voice transcription requires a supported device with iOS 26 or later.", + ); + } + + try { + const supportedLocale = await AppleTranscription.prepare(locale); + throwIfVoiceTranscriptionAborted(signal); + return { + locale: supportedLocale, + transcribe: (uri, options) => transcribeVoiceRecording(uri, supportedLocale, options), + }; + } catch (error) { + throwIfVoiceTranscriptionAborted(signal); + if (getNativeErrorCode(error) === "AppleTranscriptionUnsupportedLocale") { + throw new VoiceTranscriptionError( + "unsupported-locale", + "Voice transcription does not support this device language.", + { cause: error }, + ); + } + + throw wrapError( + "preparation-failed", + "Voice transcription could not prepare this language.", + error, + ); + } +} + +async function transcribeVoiceRecording( + uri: string, + locale: string, + { signal }: VoiceTranscriptionOptions, +): Promise { + try { + throwIfVoiceTranscriptionAborted(signal); + const audio = await new File(uri).arrayBuffer(); + throwIfVoiceTranscriptionAborted(signal); + const result = await AppleTranscription.transcribe(audio, locale); + throwIfVoiceTranscriptionAborted(signal); + return result.segments + .map((segment) => segment.text) + .join(" ") + .trim(); + } catch (error) { + throwIfVoiceTranscriptionAborted(signal); + throw wrapError("transcription-failed", "Voice transcription failed.", error); + } +} diff --git a/apps/mobile/src/native/voiceTranscription.ts b/apps/mobile/src/native/voiceTranscription.ts new file mode 100644 index 000000000000..e003064ae3f8 --- /dev/null +++ b/apps/mobile/src/native/voiceTranscription.ts @@ -0,0 +1,5 @@ +import type { VoiceTranscriber } from "@t3tools/client-runtime/voice-input"; + +export function getLocalVoiceTranscriber(): VoiceTranscriber | null { + return null; +} diff --git a/apps/mobile/src/persistence/mobile-preferences.ts b/apps/mobile/src/persistence/mobile-preferences.ts index 5d0bd8a3c9dc..cf4c29c6041c 100644 --- a/apps/mobile/src/persistence/mobile-preferences.ts +++ b/apps/mobile/src/persistence/mobile-preferences.ts @@ -31,7 +31,6 @@ export interface Preferences { /** @deprecated Kept temporarily so older OTA bundles retain the selected mode. */ readonly projectGroupingEnabled?: boolean; readonly projectGroupingMode?: SidebarProjectGroupingMode; - readonly autoSettleOnMerge?: boolean; /** * Device-local mirror of the web `legacySidebarEnabled` setting. Mobile has * no client-settings sync, so the legacy grouped thread list is opted into @@ -101,7 +100,6 @@ function sanitizePreferences(parsed: Preferences): Preferences { collapsedProjectGroups?: readonly string[]; projectGroupingEnabled?: boolean; projectGroupingMode?: SidebarProjectGroupingMode; - autoSettleOnMerge?: boolean; legacyThreadListEnabled?: boolean; planModeEnabled?: boolean; threadListV2SettledShelfExpanded?: boolean; @@ -167,9 +165,6 @@ function sanitizePreferences(parsed: Preferences): Preferences { ) { preferences.projectGroupingMode = parsed.projectGroupingMode; } - if (typeof parsed.autoSettleOnMerge === "boolean") { - preferences.autoSettleOnMerge = parsed.autoSettleOnMerge; - } if (typeof parsed.legacyThreadListEnabled === "boolean") { preferences.legacyThreadListEnabled = parsed.legacyThreadListEnabled; } diff --git a/apps/mobile/src/state/assets.ts b/apps/mobile/src/state/assets.ts index 611a1ed8b99b..9e3e43c7cdcb 100644 --- a/apps/mobile/src/state/assets.ts +++ b/apps/mobile/src/state/assets.ts @@ -2,9 +2,11 @@ import { useAtomValue } from "@effect/atom-react"; import { createAssetEnvironmentAtoms, resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; import type { AssetResource, EnvironmentId } from "@t3tools/contracts"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; +import { useCallback } from "react"; import { connectionAtomRuntime } from "../connection/runtime"; import { usePreparedConnection } from "./session"; +import { useAtomQueryRunner } from "./use-atom-query-runner"; export const assetEnvironment = createAssetEnvironmentAtoms(connectionAtomRuntime); @@ -44,3 +46,23 @@ export function useAssetUrl( const state = useAssetUrlState(environmentId, resource); return state._tag === "Success" ? state.url : null; } + +/** Explicit playback and sharing must reauthorize files that may have been replaced on disk. */ +export function useRefreshAssetUrl( + environmentId: EnvironmentId | null, + resource: AssetResource | null, +): () => Promise { + const connection = usePreparedConnection(environmentId); + const httpBaseUrl = connection._tag === "Some" ? connection.value.httpBaseUrl : null; + const createUrl = useAtomQueryRunner(assetEnvironment.createUrl, { + refresh: true, + reportFailure: false, + }); + return useCallback(async () => { + if (environmentId === null || resource === null || httpBaseUrl === null) return null; + const result = await createUrl({ environmentId, input: { resource } }); + return result._tag === "Success" + ? resolveAssetUrl(httpBaseUrl, result.value.relativeUrl) + : null; + }, [createUrl, environmentId, httpBaseUrl, resource]); +} diff --git a/apps/mobile/src/state/atom-registry.ts b/apps/mobile/src/state/atom-registry.ts index b30e7c3729a1..5dc5fab44e95 100644 --- a/apps/mobile/src/state/atom-registry.ts +++ b/apps/mobile/src/state/atom-registry.ts @@ -1,3 +1,14 @@ import { AtomRegistry } from "effect/unstable/reactivity"; +import { + disposeOnFoundationReplace, + type FoundationHotModule, +} from "../lib/foundation-fast-refresh"; + +declare const module: { readonly hot?: FoundationHotModule } | undefined; + export const appAtomRegistry = AtomRegistry.make(); + +disposeOnFoundationReplace(typeof module === "undefined" ? undefined : module.hot, () => + appAtomRegistry.dispose(), +); diff --git a/apps/mobile/src/state/attachments.ts b/apps/mobile/src/state/attachments.ts new file mode 100644 index 000000000000..3377a96c1ecf --- /dev/null +++ b/apps/mobile/src/state/attachments.ts @@ -0,0 +1,5 @@ +import { createAttachmentEnvironmentAtoms } from "@t3tools/client-runtime/state/attachments"; + +import { connectionAtomRuntime } from "../connection/runtime"; + +export const attachmentEnvironment = createAttachmentEnvironmentAtoms(connectionAtomRuntime); diff --git a/apps/mobile/src/state/auth.ts b/apps/mobile/src/state/auth.ts deleted file mode 100644 index 835dee7f7837..000000000000 --- a/apps/mobile/src/state/auth.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { createAuthEnvironmentAtoms } from "@t3tools/client-runtime/state/auth"; - -import { connectionAtomRuntime } from "../connection/runtime"; - -export const authEnvironment = createAuthEnvironmentAtoms(connectionAtomRuntime); diff --git a/apps/mobile/src/state/composer-attachment-uploads.ts b/apps/mobile/src/state/composer-attachment-uploads.ts new file mode 100644 index 000000000000..efc3fe1c39e5 --- /dev/null +++ b/apps/mobile/src/state/composer-attachment-uploads.ts @@ -0,0 +1,126 @@ +import { useAtomValue } from "@effect/atom-react"; +import type { EnvironmentId } from "@t3tools/contracts"; +import { Atom } from "effect/unstable/reactivity"; +import { useEffect, useRef } from "react"; + +import { prepareTurnAttachments } from "../lib/attachmentUpload"; +import { + composerAttachmentUploadKey, + composerDraftEnvironmentId, + canUploadComposerAttachment, + createComposerAttachmentUploadQueue, + type ComposerAttachmentUploadState, +} from "../lib/composerAttachmentUploadQueue"; +import { appAtomRegistry } from "./atom-registry"; +import { useServerConfigs } from "./entities"; +import { flattenQueuedThreadMessages, threadOutboxManager } from "./thread-outbox"; +import { useThreadOutboxMessages } from "./use-thread-outbox"; +import { + composerDraftsAtom, + ensureComposerDraftsLoaded, + flushComposerDrafts, + retainComposerAttachmentFileForPreview, + setComposerDraftAttachmentUpload, +} from "./use-composer-drafts"; +import { useRemoteConnectionStatus } from "./use-remote-environment-registry"; + +export { composerAttachmentUploadBlockReason } from "../lib/composerAttachmentUploadQueue"; + +export const composerAttachmentUploadsAtom = Atom.make< + Readonly> +>({}).pipe(Atom.keepAlive); +const uploadStateAtom = Atom.family((key: string) => + Atom.map(composerAttachmentUploadsAtom, (states) => states[key]), +); +let uploadQueue: ReturnType | null = null; + +export function useComposerAttachmentUploadState( + environmentId: EnvironmentId | undefined, + attachmentId: string, +) { + return useAtomValue( + uploadStateAtom(environmentId ? composerAttachmentUploadKey(environmentId, attachmentId) : ""), + ); +} + +export function retryComposerAttachmentUpload(environmentId: EnvironmentId, attachmentId: string) { + uploadQueue?.retry(environmentId, attachmentId); +} + +/** Runs outside mounted composers so a transfer can finish after navigation. */ +export function useComposerAttachmentUploadWorker() { + const drafts = useAtomValue(composerDraftsAtom); + const queuedMessages = useThreadOutboxMessages(); + const serverConfigs = useServerConfigs(); + const { connectedEnvironments } = useRemoteConnectionStatus(); + const queueRef = useRef | null>(null); + + useEffect(() => { + ensureComposerDraftsLoaded(); + const queue = createComposerAttachmentUploadQueue({ + onChange: (states) => appAtomRegistry.set(composerAttachmentUploadsAtom, states), + upload: async ({ environmentId, attachment }, signal, onProgress) => { + const release = + attachment.type === "file" + ? retainComposerAttachmentFileForPreview(attachment) + : undefined; + try { + const result = await prepareTurnAttachments({ + environmentId, + attachments: [attachment], + supportsImageUploads: true, + signal, + onUploadProgress: (_, progress) => onProgress(progress), + persistUploadedReferences: async ([uploaded]) => { + if (signal.aborted || !uploaded) return "abandon"; + const queued = flattenQueuedThreadMessages( + appAtomRegistry.get(threadOutboxManager.queuedMessagesByThreadKeyAtom), + ); + let retained = false; + for (const [key, draft] of Object.entries(appAtomRegistry.get(composerDraftsAtom))) { + if ( + composerDraftEnvironmentId(key, queued) === environmentId && + draft.attachments.some((candidate) => candidate.id === attachment.id) + ) { + retained = setComposerDraftAttachmentUpload(key, uploaded) || retained; + } + } + if (!retained) return "abandon"; + await flushComposerDrafts(); + return "persisted"; + }, + }); + return result.status === "ready"; + } finally { + release?.(); + } + }, + }); + queueRef.current = queue; + uploadQueue = queue; + return () => { + queue.dispose(); + if (uploadQueue === queue) uploadQueue = null; + queueRef.current = null; + }; + }, []); + + useEffect(() => { + const queued = flattenQueuedThreadMessages(queuedMessages); + const connected = new Set( + connectedEnvironments + .filter((environment) => environment.connectionState === "connected") + .map((environment) => environment.environmentId), + ); + const requests = Object.entries(drafts).flatMap(([key, draft]) => { + const environmentId = composerDraftEnvironmentId(key, queued); + if (environmentId === null || !connected.has(environmentId)) return []; + return draft.attachments + .filter((attachment) => + canUploadComposerAttachment(attachment, serverConfigs.get(environmentId)), + ) + .map((attachment) => ({ environmentId, attachment })); + }); + queueRef.current?.sync(requests); + }, [connectedEnvironments, drafts, queuedMessages, serverConfigs]); +} diff --git a/apps/mobile/src/state/git.ts b/apps/mobile/src/state/git.ts deleted file mode 100644 index 66bb3dc0bdea..000000000000 --- a/apps/mobile/src/state/git.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { createGitEnvironmentAtoms } from "@t3tools/client-runtime/state/git"; - -import { connectionAtomRuntime } from "../connection/runtime"; - -export const gitEnvironment = createGitEnvironmentAtoms(connectionAtomRuntime); diff --git a/apps/mobile/src/state/pending-task-editor-writes.test.ts b/apps/mobile/src/state/pending-task-editor-writes.test.ts new file mode 100644 index 000000000000..9305d821fd44 --- /dev/null +++ b/apps/mobile/src/state/pending-task-editor-writes.test.ts @@ -0,0 +1,353 @@ +import { CommandId, EnvironmentId, MessageId, ThreadId } from "@t3tools/contracts"; +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import type { QueuedThreadMessage } from "./thread-outbox-model"; + +const harness = vi.hoisted(() => ({ + manager: null as unknown as ReturnType< + typeof import("./thread-outbox-manager").createThreadOutboxManager + >, + writeGates: [] as Array<{ + readonly promise: Promise; + readonly started: (message: QueuedThreadMessage) => void; + }>, +})); + +vi.mock("./thread-outbox", async () => { + const { createThreadOutboxManager } = await import("./thread-outbox-manager"); + const { appAtomRegistry } = await import("./atom-registry"); + harness.manager = createThreadOutboxManager({ + registry: appAtomRegistry, + storage: { + load: async () => [], + write: async (message) => { + const pending = harness.writeGates.shift(); + if (pending) { + pending.started(message); + await pending.promise; + } + }, + remove: async () => undefined, + }, + }); + const manager = harness.manager; + return { + threadOutboxManager: manager, + flushThreadOutbox: async () => undefined, + threadOutboxRevision: (messageId: QueuedThreadMessage["messageId"]) => + manager.revisionOf(messageId), + updateThreadOutboxMessage: (message: QueuedThreadMessage, expectedRevision?: number) => + manager.update(message, expectedRevision), + }; +}); + +import { appAtomRegistry } from "./atom-registry"; +import { + capturePendingTaskEditorWriteBaseline, + flushPendingTaskEditorWrite, +} from "./pending-task-editor-writes"; +import { + composerDraftsAtom, + getComposerDraftSnapshot, + type ComposerDraft, +} from "./use-composer-drafts"; + +function queuedMessage(messageId: string, text: string): QueuedThreadMessage { + return { + environmentId: EnvironmentId.make("environment-1"), + threadId: ThreadId.make("thread-1"), + messageId: MessageId.make(messageId), + commandId: CommandId.make(`command-${messageId}`), + text, + attachments: [], + createdAt: "2026-08-28T12:00:00.000Z", + }; +} + +function draft(text: string): ComposerDraft { + return { + text, + attachments: [], + runtimeMode: "full-access", + }; +} + +function setDraft(draftKey: string, value: ComposerDraft): void { + appAtomRegistry.set(composerDraftsAtom, { [draftKey]: value }); +} + +function queuedMessageText(messageId: QueuedThreadMessage["messageId"]): string | null { + const messages = Object.values( + appAtomRegistry.get(harness.manager.queuedMessagesByThreadKeyAtom), + ).flat(); + return messages.find((message) => message.messageId === messageId)?.text ?? null; +} + +function blockNextWrite() { + let resolveWrite!: () => void; + let rejectWrite!: (error: Error) => void; + let markStarted!: (message: QueuedThreadMessage) => void; + const promise = new Promise((resolve, reject) => { + resolveWrite = resolve; + rejectWrite = reject; + }); + const started = new Promise((resolve) => { + markStarted = resolve; + }); + harness.writeGates.push({ promise, started: markStarted }); + return { + started, + resolve: resolveWrite, + reject: rejectWrite, + }; +} + +beforeEach(() => { + harness.writeGates.length = 0; + appAtomRegistry.set(harness.manager.queuedMessagesByThreadKeyAtom, {}); + appAtomRegistry.set(composerDraftsAtom, {}); +}); + +describe("pending task editor writes", () => { + it("chains a reopened editor that closes before the previous save finishes", async () => { + const original = queuedMessage("message-close-before-save", "original"); + const firstEdit = queuedMessage("message-close-before-save", "first edit"); + const secondEdit = queuedMessage("message-close-before-save", "second edit"); + const draftKey = "pending-task:message-close-before-save"; + await harness.manager.enqueue(original); + + setDraft(draftKey, draft(firstEdit.text)); + const firstBaseline = capturePendingTaskEditorWriteBaseline(original.messageId); + const firstWriteGate = blockNextWrite(); + const firstSave = flushPendingTaskEditorWrite({ + message: firstEdit, + baseline: firstBaseline, + draftKey, + }); + await firstWriteGate.started; + + const secondBaseline = capturePendingTaskEditorWriteBaseline(original.messageId); + setDraft(draftKey, draft(secondEdit.text)); + const secondWriteGate = blockNextWrite(); + const secondSave = flushPendingTaskEditorWrite({ + message: secondEdit, + baseline: secondBaseline, + draftKey, + }); + + firstWriteGate.resolve(); + await expect(firstSave).resolves.toBe(false); + await expect(secondWriteGate.started).resolves.toMatchObject({ text: secondEdit.text }); + secondWriteGate.resolve(); + + await expect(secondSave).resolves.toBe(true); + expect(queuedMessageText(original.messageId)).toBe(secondEdit.text); + }); + + it("keeps a captured predecessor after that predecessor finishes", async () => { + const original = queuedMessage("message-finished-predecessor", "original"); + const firstEdit = queuedMessage("message-finished-predecessor", "first edit"); + const secondEdit = queuedMessage("message-finished-predecessor", "second edit"); + const draftKey = "pending-task:message-finished-predecessor"; + await harness.manager.enqueue(original); + + setDraft(draftKey, draft(firstEdit.text)); + const firstWriteGate = blockNextWrite(); + const firstSave = flushPendingTaskEditorWrite({ + message: firstEdit, + baseline: capturePendingTaskEditorWriteBaseline(original.messageId), + draftKey, + }); + await firstWriteGate.started; + const secondBaseline = capturePendingTaskEditorWriteBaseline(original.messageId); + + firstWriteGate.resolve(); + await expect(firstSave).resolves.toBe(true); + + setDraft(draftKey, draft(secondEdit.text)); + const secondWriteGate = blockNextWrite(); + const secondSave = flushPendingTaskEditorWrite({ + message: secondEdit, + baseline: secondBaseline, + draftKey, + }); + await secondWriteGate.started; + secondWriteGate.resolve(); + + await expect(secondSave).resolves.toBe(true); + expect(queuedMessageText(original.messageId)).toBe(secondEdit.text); + }); + + it("chains three rapid editor saves in order", async () => { + const original = queuedMessage("message-three-saves", "original"); + const firstEdit = queuedMessage("message-three-saves", "first edit"); + const secondEdit = queuedMessage("message-three-saves", "second edit"); + const thirdEdit = queuedMessage("message-three-saves", "third edit"); + const draftKey = "pending-task:message-three-saves"; + await harness.manager.enqueue(original); + + setDraft(draftKey, draft(firstEdit.text)); + const firstWriteGate = blockNextWrite(); + const firstSave = flushPendingTaskEditorWrite({ + message: firstEdit, + baseline: capturePendingTaskEditorWriteBaseline(original.messageId), + draftKey, + }); + await firstWriteGate.started; + + const secondBaseline = capturePendingTaskEditorWriteBaseline(original.messageId); + setDraft(draftKey, draft(secondEdit.text)); + const secondWriteGate = blockNextWrite(); + const secondSave = flushPendingTaskEditorWrite({ + message: secondEdit, + baseline: secondBaseline, + draftKey, + }); + + const thirdBaseline = capturePendingTaskEditorWriteBaseline(original.messageId); + setDraft(draftKey, draft(thirdEdit.text)); + const thirdWriteGate = blockNextWrite(); + const thirdSave = flushPendingTaskEditorWrite({ + message: thirdEdit, + baseline: thirdBaseline, + draftKey, + }); + + firstWriteGate.resolve(); + await expect(firstSave).resolves.toBe(false); + await secondWriteGate.started; + secondWriteGate.resolve(); + await expect(secondSave).resolves.toBe(false); + await thirdWriteGate.started; + thirdWriteGate.resolve(); + + await expect(thirdSave).resolves.toBe(true); + expect(queuedMessageText(original.messageId)).toBe(thirdEdit.text); + }); + + it("keeps the handed-off revision when the middle editor write fails", async () => { + const original = queuedMessage("message-middle-failure", "original"); + const firstEdit = queuedMessage("message-middle-failure", "first edit"); + const failedEdit = queuedMessage("message-middle-failure", "failed edit"); + const finalEdit = queuedMessage("message-middle-failure", "final edit"); + const draftKey = "pending-task:message-middle-failure"; + await harness.manager.enqueue(original); + + setDraft(draftKey, draft(firstEdit.text)); + const firstWriteGate = blockNextWrite(); + const firstSave = flushPendingTaskEditorWrite({ + message: firstEdit, + baseline: capturePendingTaskEditorWriteBaseline(original.messageId), + draftKey, + }); + await firstWriteGate.started; + + const failedBaseline = capturePendingTaskEditorWriteBaseline(original.messageId); + setDraft(draftKey, draft(failedEdit.text)); + const failedWriteGate = blockNextWrite(); + const failedSave = flushPendingTaskEditorWrite({ + message: failedEdit, + baseline: failedBaseline, + draftKey, + }); + + const finalBaseline = capturePendingTaskEditorWriteBaseline(original.messageId); + setDraft(draftKey, draft(finalEdit.text)); + const finalWriteGate = blockNextWrite(); + const finalSave = flushPendingTaskEditorWrite({ + message: finalEdit, + baseline: finalBaseline, + draftKey, + }); + + firstWriteGate.resolve(); + await expect(firstSave).resolves.toBe(false); + await failedWriteGate.started; + failedWriteGate.reject(new Error("disk full")); + await expect(failedSave).rejects.toMatchObject({ _tag: "ThreadOutboxManagerError" }); + await finalWriteGate.started; + finalWriteGate.resolve(); + + await expect(finalSave).resolves.toBe(true); + expect(queuedMessageText(original.messageId)).toBe(finalEdit.text); + }); + + it("does not overwrite an unrelated update accepted after capture", async () => { + const original = queuedMessage("message-unrelated-update", "original"); + const editorEdit = queuedMessage("message-unrelated-update", "editor edit"); + const unrelatedEdit = queuedMessage("message-unrelated-update", "unrelated edit"); + const draftKey = "pending-task:message-unrelated-update"; + await harness.manager.enqueue(original); + + const editorBaseline = capturePendingTaskEditorWriteBaseline(original.messageId); + const revision = harness.manager.revisionOf(original.messageId); + await expect(harness.manager.update(unrelatedEdit, revision)).resolves.toBe(true); + setDraft(draftKey, draft(editorEdit.text)); + + await expect( + flushPendingTaskEditorWrite({ + message: editorEdit, + baseline: editorBaseline, + draftKey, + }), + ).resolves.toBe(false); + expect(queuedMessageText(original.messageId)).toBe(unrelatedEdit.text); + }); + + it("lets a later editor retry after its predecessor write fails", async () => { + const original = queuedMessage("message-write-retry", "original"); + const failedEdit = queuedMessage("message-write-retry", "failed edit"); + const retryEdit = queuedMessage("message-write-retry", "retry edit"); + const draftKey = "pending-task:message-write-retry"; + await harness.manager.enqueue(original); + + setDraft(draftKey, draft(failedEdit.text)); + const failedWriteGate = blockNextWrite(); + const failedSave = flushPendingTaskEditorWrite({ + message: failedEdit, + baseline: capturePendingTaskEditorWriteBaseline(original.messageId), + draftKey, + }); + await failedWriteGate.started; + + const retryBaseline = capturePendingTaskEditorWriteBaseline(original.messageId); + setDraft(draftKey, draft(retryEdit.text)); + const retryWriteGate = blockNextWrite(); + const retrySave = flushPendingTaskEditorWrite({ + message: retryEdit, + baseline: retryBaseline, + draftKey, + }); + + failedWriteGate.reject(new Error("disk full")); + await expect(failedSave).rejects.toMatchObject({ _tag: "ThreadOutboxManagerError" }); + await retryWriteGate.started; + retryWriteGate.resolve(); + + await expect(retrySave).resolves.toBe(true); + expect(queuedMessageText(original.messageId)).toBe(retryEdit.text); + }); + + it("does not permit cleanup after a newer editor makes the draft unsendable", async () => { + const original = queuedMessage("message-unsendable-draft", "original"); + const editorEdit = queuedMessage("message-unsendable-draft", "saved edit"); + const draftKey = "pending-task:message-unsendable-draft"; + await harness.manager.enqueue(original); + + setDraft(draftKey, draft(editorEdit.text)); + const writeGate = blockNextWrite(); + const save = flushPendingTaskEditorWrite({ + message: editorEdit, + baseline: capturePendingTaskEditorWriteBaseline(original.messageId), + draftKey, + }); + await writeGate.started; + + setDraft(draftKey, draft("")); + writeGate.resolve(); + + await expect(save).resolves.toBe(false); + expect(getComposerDraftSnapshot(draftKey).text).toBe(""); + expect(queuedMessageText(original.messageId)).toBe(editorEdit.text); + }); +}); diff --git a/apps/mobile/src/state/pending-task-editor-writes.ts b/apps/mobile/src/state/pending-task-editor-writes.ts new file mode 100644 index 000000000000..e8e10a24626f --- /dev/null +++ b/apps/mobile/src/state/pending-task-editor-writes.ts @@ -0,0 +1,89 @@ +import type { QueuedThreadMessage } from "./thread-outbox"; +import { threadOutboxRevision, updateThreadOutboxMessage } from "./thread-outbox"; +import { getComposerDraftSnapshot, sameComposerDraftState } from "./use-composer-drafts"; + +type PendingTaskEditorWriteResult = + | { + readonly status: "complete"; + readonly updated: boolean; + readonly nextRevision: number; + } + | { + readonly status: "failed"; + readonly error: unknown; + readonly nextRevision: number; + }; + +const pendingWrites = new Map< + QueuedThreadMessage["messageId"], + Promise +>(); + +/** + * Captures this editor's outbox revision and any editor save it must follow. + * The returned promise keeps that predecessor even after its map entry clears. + */ +export function capturePendingTaskEditorWriteBaseline( + messageId: QueuedThreadMessage["messageId"], +): Promise { + const capturedRevision = threadOutboxRevision(messageId); + const predecessor = pendingWrites.get(messageId); + if (!predecessor) { + return Promise.resolve(capturedRevision); + } + return predecessor.then( + ({ nextRevision }) => Math.max(capturedRevision, nextRevision), + () => capturedRevision, + ); +} + +/** + * Saves one dismissed editor after its captured predecessor. A true result + * means both the outbox write and this editor's draft snapshot still match. + */ +export function flushPendingTaskEditorWrite(input: { + readonly message: QueuedThreadMessage; + readonly baseline: Promise; + readonly draftKey: string; +}): Promise { + const { message } = input; + const draftSnapshot = getComposerDraftSnapshot(input.draftKey); + const write = input.baseline.then( + async (expectedRevision): Promise => { + try { + const updated = await updateThreadOutboxMessage(message, expectedRevision); + return { + status: "complete", + updated, + nextRevision: expectedRevision + (updated ? 1 : 0), + }; + } catch (error) { + // A failed write does not advance the outbox, but later editor saves + // still need the expected revision handed off by its predecessor. + return { + status: "failed", + error, + nextRevision: expectedRevision, + }; + } + }, + ); + + pendingWrites.set(message.messageId, write); + const removeWrite = (): void => { + if (pendingWrites.get(message.messageId) === write) { + pendingWrites.delete(message.messageId); + } + }; + void write.then(removeWrite, removeWrite); + + return write.then((result) => { + if (result.status === "failed") { + throw result.error; + } + return ( + result.updated && + sameComposerDraftState(draftSnapshot, getComposerDraftSnapshot(input.draftKey)) + ); + }); +} diff --git a/apps/mobile/src/state/remote-environment-projections.test.ts b/apps/mobile/src/state/remote-environment-projections.test.ts new file mode 100644 index 000000000000..c0877c2d1942 --- /dev/null +++ b/apps/mobile/src/state/remote-environment-projections.test.ts @@ -0,0 +1,161 @@ +import type { + EnvironmentPresentation, + PreparedConnection, +} from "@t3tools/client-runtime/connection"; +import { PrimaryConnectionTarget } from "@t3tools/client-runtime/connection"; +import type { ServerConfig } from "@t3tools/contracts"; +import { EnvironmentId } from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Option from "effect/Option"; +import { Atom, AtomRegistry } from "effect/unstable/reactivity"; + +import { createRemoteEnvironmentProjectionAtoms } from "./remote-environment-projections"; + +const ENVIRONMENT_ID = EnvironmentId.make("environment-1"); +const OTHER_ENVIRONMENT_ID = EnvironmentId.make("environment-2"); + +function target(environmentId: EnvironmentId, endpoint: string = environmentId) { + return new PrimaryConnectionTarget({ + environmentId, + label: `Environment ${environmentId}`, + httpBaseUrl: `https://${endpoint}.example.test`, + wsBaseUrl: `wss://${endpoint}.example.test`, + }); +} + +function presentation( + environmentId: EnvironmentId, + endpoint: string = environmentId, + serverConfig: ServerConfig | null = null, +): EnvironmentPresentation { + return { + entry: { target: target(environmentId, endpoint), profile: Option.none() }, + connection: { phase: "connected", error: null, traceId: null }, + serverConfig, + }; +} + +function prepared( + environmentId: EnvironmentId, + endpoint: string, + token: string, +): PreparedConnection { + return { + environmentId, + label: `Environment ${environmentId}`, + httpBaseUrl: `https://${endpoint}.example.test`, + socketUrl: `wss://${endpoint}.example.test/ws?token=redacted`, + httpAuthorization: { _tag: "Bearer", token }, + target: target(environmentId, endpoint), + }; +} + +function makeHarness() { + const presentationAtoms = Atom.family((environmentId: EnvironmentId) => + Atom.make(presentation(environmentId)), + ); + const preparedConnectionAtoms = Atom.family((_environmentId: EnvironmentId) => + Atom.make>(Option.none()), + ); + const serverConfigAtoms = Atom.family((_environmentId: EnvironmentId) => + Atom.make(null), + ); + const projections = createRemoteEnvironmentProjectionAtoms({ + presentationAtom: presentationAtoms, + preparedConnectionAtom: preparedConnectionAtoms, + serverConfigAtom: serverConfigAtoms, + }); + + return { + registry: AtomRegistry.make(), + presentationAtom: presentationAtoms, + preparedConnectionAtom: preparedConnectionAtoms, + serverConfigAtom: serverConfigAtoms, + projections, + }; +} + +describe("remote environment projections", () => { + it("shares each environment projection and invalidates only changed inputs", () => { + const harness = makeHarness(); + const firstConsumer = Atom.make((get) => + get(harness.projections.savedConnectionAtom(ENVIRONMENT_ID)), + ); + const secondConsumer = Atom.make((get) => + get(harness.projections.savedConnectionAtom(ENVIRONMENT_ID)), + ); + const otherConsumer = Atom.make((get) => + get(harness.projections.savedConnectionAtom(OTHER_ENVIRONMENT_ID)), + ); + const initial = harness.registry.get(firstConsumer); + const otherInitial = harness.registry.get(otherConsumer); + + expect(harness.registry.get(secondConsumer)).toBe(initial); + expect(initial).toMatchObject({ + environmentLabel: "Environment environment-1", + pairingUrl: "https://environment-1.example.test", + displayUrl: "https://environment-1.example.test", + httpBaseUrl: "https://environment-1.example.test", + wsBaseUrl: "wss://environment-1.example.test", + bearerToken: null, + }); + + harness.registry.set( + harness.preparedConnectionAtom(ENVIRONMENT_ID), + Option.some(prepared(ENVIRONMENT_ID, "rotated", "rotated-token")), + ); + const rotated = harness.registry.get(firstConsumer); + + expect(rotated).not.toBe(initial); + expect(rotated).toMatchObject({ + httpBaseUrl: "https://rotated.example.test", + wsBaseUrl: "wss://rotated.example.test", + bearerToken: "rotated-token", + }); + expect(harness.registry.get(secondConsumer)).toBe(rotated); + expect(harness.registry.get(otherConsumer)).toBe(otherInitial); + + harness.registry.set(harness.preparedConnectionAtom(ENVIRONMENT_ID), Option.none()); + harness.registry.set( + harness.presentationAtom(ENVIRONMENT_ID), + presentation(ENVIRONMENT_ID, "catalog-updated"), + ); + + expect(harness.registry.get(firstConsumer)).toMatchObject({ + displayUrl: "https://catalog-updated.example.test", + httpBaseUrl: "https://catalog-updated.example.test", + wsBaseUrl: "wss://catalog-updated.example.test", + bearerToken: null, + }); + }); + + it("preserves saved identity across config-only updates and refreshes runtime state", () => { + const harness = makeHarness(); + const savedAtom = harness.projections.savedConnectionAtom(ENVIRONMENT_ID); + const runtimeAtom = harness.projections.runtimeStateAtom(ENVIRONMENT_ID); + const savedInitial = harness.registry.get(savedAtom); + const runtimeInitial = harness.registry.get(runtimeAtom); + const config = { cwd: "/repo" } as ServerConfig; + const initialPresentation = harness.registry.get(harness.presentationAtom(ENVIRONMENT_ID)); + + harness.registry.set( + harness.presentationAtom(ENVIRONMENT_ID), + initialPresentation === null ? null : { ...initialPresentation, serverConfig: config }, + ); + harness.registry.set(harness.serverConfigAtom(ENVIRONMENT_ID), config); + + expect(harness.registry.get(savedAtom)).toBe(savedInitial); + expect(harness.registry.get(runtimeAtom)).not.toBe(runtimeInitial); + expect(harness.registry.get(runtimeAtom)?.serverConfig).toBe(config); + }); + + it("keeps missing environments null", () => { + const harness = makeHarness(); + harness.registry.set(harness.presentationAtom(ENVIRONMENT_ID), null); + + expect( + harness.registry.get(harness.projections.savedConnectionAtom(ENVIRONMENT_ID)), + ).toBeNull(); + expect(harness.registry.get(harness.projections.runtimeStateAtom(ENVIRONMENT_ID))).toBeNull(); + }); +}); diff --git a/apps/mobile/src/state/remote-environment-projections.ts b/apps/mobile/src/state/remote-environment-projections.ts new file mode 100644 index 000000000000..b1315c299d66 --- /dev/null +++ b/apps/mobile/src/state/remote-environment-projections.ts @@ -0,0 +1,120 @@ +import type { + EnvironmentPresentation, + PreparedConnection, +} from "@t3tools/client-runtime/connection"; +import { connectionCatalogDisplayUrl } from "@t3tools/client-runtime/connection"; +import type { EnvironmentId, ServerConfig } from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import { Atom } from "effect/unstable/reactivity"; + +import type { SavedRemoteConnection } from "../lib/connection"; +import type { EnvironmentRuntimeState } from "./remote-runtime-types"; + +export function createRemoteEnvironmentProjectionAtoms(input: { + readonly presentationAtom: ( + environmentId: EnvironmentId, + ) => Atom.Atom; + readonly preparedConnectionAtom: ( + environmentId: EnvironmentId, + ) => Atom.Atom>; + readonly serverConfigAtom: (environmentId: EnvironmentId) => Atom.Atom; +}) { + const savedConnectionAtom = Atom.family((environmentId: EnvironmentId) => { + let previousEntry: EnvironmentPresentation["entry"] | null = null; + let previousPrepared: PreparedConnection | null = null; + let previous: SavedRemoteConnection | null = null; + + return Atom.make((get) => { + const presentation = get(input.presentationAtom(environmentId)); + if (presentation === null) { + previousEntry = null; + previousPrepared = null; + previous = null; + return null; + } + + const prepared = Option.getOrNull(get(input.preparedConnectionAtom(environmentId))); + if ( + previous !== null && + presentation.entry === previousEntry && + prepared === previousPrepared + ) { + return previous; + } + + const displayUrl = connectionCatalogDisplayUrl(presentation.entry) ?? ""; + const httpBaseUrl = prepared?.httpBaseUrl ?? displayUrl; + const socketUrl = prepared?.socketUrl ?? ""; + const wsBaseUrl = + socketUrl === "" + ? displayUrl.startsWith("https://") + ? displayUrl.replace(/^https:/, "wss:") + : displayUrl.replace(/^http:/, "ws:") + : new URL(socketUrl).origin; + const authorization = prepared?.httpAuthorization ?? null; + const relayManaged = presentation.entry.target._tag === "RelayConnectionTarget"; + + previousEntry = presentation.entry; + previousPrepared = prepared; + previous = { + environmentId, + environmentLabel: presentation.entry.target.label, + pairingUrl: displayUrl, + displayUrl, + httpBaseUrl, + wsBaseUrl, + bearerToken: authorization?._tag === "Bearer" ? authorization.token : null, + ...(relayManaged + ? { + authenticationMethod: "dpop" as const, + relayManaged: true as const, + ...(authorization?._tag === "Dpop" + ? { dpopAccessToken: authorization.accessToken } + : {}), + } + : { authenticationMethod: "bearer" as const }), + }; + return previous; + }).pipe(Atom.withLabel(`mobile:saved-connection:${environmentId}`)); + }); + + const runtimeStateAtom = Atom.family((environmentId: EnvironmentId) => { + let previousConnection: EnvironmentPresentation["connection"] | null = null; + let previousServerConfig: ServerConfig | null = null; + let previous: EnvironmentRuntimeState | null = null; + + return Atom.make((get) => { + const presentation = get(input.presentationAtom(environmentId)); + if (presentation === null) { + previousConnection = null; + previousServerConfig = null; + previous = null; + return null; + } + + const connection = presentation.connection; + const serverConfig = get(input.serverConfigAtom(environmentId)); + if ( + previous !== null && + connection.phase === previousConnection?.phase && + connection.error === previousConnection?.error && + connection.traceId === previousConnection?.traceId && + serverConfig === previousServerConfig + ) { + return previous; + } + + previousConnection = connection; + previousServerConfig = serverConfig; + previous = { + connectionState: connection.phase, + connectionError: connection.error, + connectionErrorTraceId: connection.traceId, + serverConfig, + }; + return previous; + }).pipe(Atom.withLabel(`mobile:environment-runtime-state:${environmentId}`)); + }); + + return { savedConnectionAtom, runtimeStateAtom }; +} diff --git a/apps/mobile/src/state/thread-outbox-manager.ts b/apps/mobile/src/state/thread-outbox-manager.ts index f6a20ccffc2a..1bd2fbd8e4d0 100644 --- a/apps/mobile/src/state/thread-outbox-manager.ts +++ b/apps/mobile/src/state/thread-outbox-manager.ts @@ -46,8 +46,15 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) { ((message: string, error: unknown) => { console.warn(message, error); }); - let loadPromise: Promise | null = null; + let loadPromise: Promise | null = null; let mutationQueue: Promise = Promise.resolve(); + // Monotonic per-message write counter. Every accepted write (enqueue publish + // or update) bumps it, so a writer that captured a revision before slow work + // (an attachment upload) is rejected before its stale payload reaches disk. + const revisions = new Map(); + const bumpRevision = (messageId: MessageId): void => { + revisions.set(messageId, (revisions.get(messageId) ?? 0) + 1); + }; const serialize = (mutation: () => Promise): Promise => { const result = mutationQueue.then(mutation, mutation); @@ -65,13 +72,17 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) { options.registry.set(queuedMessagesByThreadKeyAtom, groupQueuedThreadMessages(messages)); }; - const load = (): Promise => { + // Resolves true when hydration completed; false when the read failed (the + // next call retries). Destructive callers (the attachment sweep) must not + // treat a failed hydration as an empty queue. + const load = (): Promise => { if (loadPromise !== null) { return loadPromise; } loadPromise = serialize(async () => { const persistedMessages = await options.storage.load(); setMessages([...persistedMessages, ...currentMessages()]); + return true; }).catch((cause) => { loadPromise = null; warn( @@ -84,6 +95,7 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) { cause, }), ); + return false; }); return loadPromise; }; @@ -93,6 +105,7 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) { // the message back out if it fails (durability only matters for crash // recovery, not for the in-session queue). const enqueue = (message: QueuedThreadMessage): Promise => { + bumpRevision(message.messageId); setMessages([ ...currentMessages().filter((candidate) => candidate.messageId !== message.messageId), message, @@ -105,6 +118,17 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) { // id may have optimistically replaced this attempt while the write was // in flight, and its entry must survive this attempt's failure. setMessages(currentMessages().filter((candidate) => candidate !== message)); + // A concurrent update losing its post-write race compensates by + // persisting this message's payload before this write settles. When + // no same-id entry survives the rollback, drop that disk copy too, or + // a restart resurrects a message the queue no longer holds. + if (!currentMessages().some((candidate) => candidate.messageId === message.messageId)) { + try { + await options.storage.remove(message); + } catch { + // Best effort: bootstrap reconciles the queue against storage. + } + } throw new ThreadOutboxManagerError({ operation: "enqueue", environmentId: message.environmentId, @@ -126,12 +150,22 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) { // Rewrites an already-queued message. A no-op when the message has been // removed in the meantime (e.g. deleted or delivered), so a trailing editor // flush can never resurrect it. Returns whether the message was updated. - const update = (message: QueuedThreadMessage): Promise => + // + // `expectedRevision` makes the update a compare-and-set: pass the revision + // read before starting slow work, and the update is rejected before the + // stale payload is persisted when any other write was accepted since. An + // enqueue can still publish synchronously while the durable write below is + // in flight, so the revision is re-checked after the write too; the stale + // payload it just persisted is then overwritten with the winning payload + // inside this mutation, so a crash before the winner's own serialized write + // cannot leave stale state on disk. + const update = (message: QueuedThreadMessage, expectedRevision?: number): Promise => serialize(async () => { - const exists = currentMessages().some( - (candidate) => candidate.messageId === message.messageId, - ); - if (!exists) { + const staleOrMissing = (): boolean => + !currentMessages().some((candidate) => candidate.messageId === message.messageId) || + (expectedRevision !== undefined && + (revisions.get(message.messageId) ?? 0) !== expectedRevision); + if (staleOrMissing()) { return false; } try { @@ -145,6 +179,21 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) { cause, }); } + if (staleOrMissing()) { + const winner = currentMessages().find( + (candidate) => candidate.messageId === message.messageId, + ); + if (winner !== undefined) { + try { + await options.storage.write(winner); + } catch { + // The winner's own serialized write follows this mutation and + // owns the failure handling for its payload. + } + } + return false; + } + bumpRevision(message.messageId); setMessages([ ...currentMessages().filter((candidate) => candidate.messageId !== message.messageId), message, @@ -152,8 +201,29 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) { return true; }); - const remove = (message: QueuedThreadMessage): Promise => + // `expectedRevision` makes the removal a compare-and-set too: an edit + // accepted after the caller decided to remove (restore-to-composer reads + // the payload it is about to delete) keeps the newer message queued. + // `canRemove` adds a live ownership check for state such as an open editor, + // which can change without writing a new message revision. + const remove = ( + message: QueuedThreadMessage, + expectedRevision?: number, + canRemove?: () => boolean, + ): Promise => serialize(async () => { + const removalCanceled = (): boolean => + (expectedRevision !== undefined && + (revisions.get(message.messageId) ?? 0) !== expectedRevision) || + canRemove?.() === false; + if (removalCanceled()) { + return null; + } + // The live payload may carry attachments an accepted update added after + // the caller's snapshot; the caller releases files from what actually + // leaves the queue. + const removed = + currentMessages().find((candidate) => candidate.messageId === message.messageId) ?? message; try { await options.storage.remove(message); } catch (cause) { @@ -165,13 +235,46 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) { cause, }); } + if (removalCanceled()) { + // An enqueue or editor lock can win while storage removal is in + // flight. Restore the live payload here, before any queued mutation + // gets its turn, so this canceled removal is durable on its own. + const winner = currentMessages().find( + (candidate) => candidate.messageId === message.messageId, + ); + if (winner !== undefined) { + try { + await options.storage.write(winner); + } catch (cause) { + throw new ThreadOutboxManagerError({ + operation: "remove", + environmentId: message.environmentId, + threadId: message.threadId, + messageId: message.messageId, + cause, + }); + } + } + return null; + } setMessages( currentMessages().filter((candidate) => candidate.messageId !== message.messageId), ); + // Tombstone, not delete: a same-id retry restarting at revision 1 would + // otherwise match a stale writer's expectedRevision from before the + // removal (ABA). + bumpRevision(message.messageId); + return removed; }); - const clearEnvironment = (environmentId: EnvironmentId): Promise => - serialize(async () => { + const clearEnvironment = ( + environmentId: EnvironmentId, + ): Promise> => { + // Enqueues publish before their serialized writes. Capture revisions now, + // but wait for earlier mutations before reading messages: a message that + // changes after this request must not enter the clear set. + const revisionsAtRequest = new Map(revisions); + return serialize(async () => { const persisted = await options.storage.load().catch((cause) => { warn( "[thread-outbox] failed to load messages while clearing environment", @@ -188,32 +291,91 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) { const allMessages = flattenQueuedThreadMessages( groupQueuedThreadMessages([...persisted, ...currentMessages()]), ); - const removedMessageIds = new Set(); + const candidates = allMessages.filter( + (message) => + message.environmentId === environmentId && + (revisions.get(message.messageId) ?? 0) === + (revisionsAtRequest.get(message.messageId) ?? 0), + ); + const candidateRevisions = new Map( + candidates.map( + (message) => [message.messageId, revisions.get(message.messageId) ?? 0] as const, + ), + ); + const removedFromStorage = new Set(); await Promise.all( - allMessages - .filter((message) => message.environmentId === environmentId) - .map(async (message) => { - try { - await options.storage.remove(message); - removedMessageIds.add(message.messageId); - } catch (cause) { - warn( - "[thread-outbox] failed to clear persisted message", - new ThreadOutboxManagerError({ - operation: "clear-environment-remove", - environmentId: message.environmentId, - threadId: message.threadId, - messageId: message.messageId, - cause, - }), - ); - } - }), + candidates.map(async (message) => { + try { + await options.storage.remove(message); + removedFromStorage.add(message.messageId); + } catch (cause) { + warn( + "[thread-outbox] failed to clear persisted message", + new ThreadOutboxManagerError({ + operation: "clear-environment-remove", + environmentId: message.environmentId, + threadId: message.threadId, + messageId: message.messageId, + cause, + }), + ); + } + }), ); - setMessages(allMessages.filter((message) => !removedMessageIds.has(message.messageId))); + // A same-id enqueue can publish while one of the removes above waits. + // Put its payload back before the later serialized enqueue write runs. + await Promise.all( + candidates.map(async (message) => { + if ( + !removedFromStorage.has(message.messageId) || + (revisions.get(message.messageId) ?? 0) === candidateRevisions.get(message.messageId) + ) { + return; + } + const retained = currentMessages().find( + (candidate) => candidate.messageId === message.messageId, + ); + if (retained === undefined) { + return; + } + try { + await options.storage.write(retained); + } catch (cause) { + warn( + "[thread-outbox] failed to restore message retained during environment clear", + new ThreadOutboxManagerError({ + operation: "clear-environment-remove", + environmentId: retained.environmentId, + threadId: retained.threadId, + messageId: retained.messageId, + cause, + }), + ); + } + }), + ); + + const removed = candidates.filter( + (message) => + removedFromStorage.has(message.messageId) && + (revisions.get(message.messageId) ?? 0) === candidateRevisions.get(message.messageId), + ); + const removedMessageIds = new Set(removed.map((message) => message.messageId)); + const reconciledMessages = flattenQueuedThreadMessages( + groupQueuedThreadMessages([...allMessages, ...currentMessages()]), + ).filter((message) => !removedMessageIds.has(message.messageId)); + for (const message of removed) { + bumpRevision(message.messageId); + } + setMessages(reconciledMessages); + // The caller releases these messages' attachment files; reporting what + // was actually removed keeps the release set honest even when this + // function's own load produced the messages. + return removed; }); + }; return { queuedMessagesByThreadKeyAtom, @@ -221,6 +383,8 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) { load, enqueue, confirmQueued, + /** Current write revision for a queued message; input to update's CAS. */ + revisionOf: (messageId: MessageId): number => revisions.get(messageId) ?? 0, update, remove, clearEnvironment, diff --git a/apps/mobile/src/state/thread-outbox-model.ts b/apps/mobile/src/state/thread-outbox-model.ts index eede506976a7..ed1d289cee12 100644 --- a/apps/mobile/src/state/thread-outbox-model.ts +++ b/apps/mobile/src/state/thread-outbox-model.ts @@ -1,4 +1,8 @@ import { isTransportConnectionErrorMessage } from "@t3tools/client-runtime/errors"; +import { + clampFileAttachmentUploadBytes, + fileAttachmentTooLargeMessage, +} from "@t3tools/client-runtime/state/attachments"; import type { EnvironmentShellStatus } from "@t3tools/client-runtime/state/shell"; import { CommandId, @@ -17,8 +21,8 @@ import { } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; -import { DraftComposerImageAttachmentSchema } from "../lib/composer-image-schema"; -import type { DraftComposerImageAttachment } from "../lib/composerImages"; +import { DraftComposerAttachmentSchema } from "../lib/composer-image-schema"; +import type { DraftComposerAttachment } from "../lib/composerImages"; import { scopedThreadKey } from "../lib/scopedEntities"; const THREAD_OUTBOX_SCHEMA_VERSION = 3; @@ -43,7 +47,7 @@ export const QueuedThreadMessageSchema = Schema.Struct({ messageId: MessageId, commandId: CommandId, text: Schema.String, - attachments: Schema.Array(DraftComposerImageAttachmentSchema), + attachments: Schema.Array(DraftComposerAttachmentSchema), modelSelection: Schema.optional(ModelSelection), runtimeMode: Schema.optional(RuntimeMode), interactionMode: Schema.optional(ProviderInteractionMode), @@ -72,7 +76,7 @@ export interface QueuedThreadMessage { readonly messageId: MessageId; readonly commandId: CommandId; readonly text: string; - readonly attachments: ReadonlyArray; + readonly attachments: ReadonlyArray; readonly modelSelection?: ModelSelectionType; readonly runtimeMode?: RuntimeModeType; readonly interactionMode?: ProviderInteractionModeType; @@ -172,6 +176,48 @@ export function resolveThreadOutboxDeliveryAction(input: { return input.environmentConnected ? "send" : "wait"; } +export type ThreadOutboxDispatchStep = + | { readonly step: "wait" } + | { readonly step: "remove" } + | { readonly step: "retry" } + | { readonly step: "restore"; readonly reason: string } + | { readonly step: "send" }; + +/** + * Orders the resolved delivery action against the file-capability gate. The + * gate applies only to a message that will send: a message whose thread + * already exists (or is gone) must be removed even while the server config is + * still loading, and a missing config defers with a retry instead of parking + * the message forever. + */ +export function resolveThreadOutboxDispatchStep(input: { + readonly deliveryAction: ThreadOutboxDeliveryAction; + readonly fileAttachments: ReadonlyArray<{ readonly name: string; readonly sizeBytes: number }>; + /** Null while the environment's server config has not synced yet. */ + readonly serverConfig: { readonly maxFileUploadBytes: number | undefined } | null; +}): ThreadOutboxDispatchStep { + if (input.deliveryAction !== "send") { + return { step: input.deliveryAction }; + } + if (input.fileAttachments.length === 0) { + return { step: "send" }; + } + if (input.serverConfig === null) { + return { step: "retry" }; + } + const maxBytes = input.serverConfig.maxFileUploadBytes; + if (maxBytes === undefined) { + return { step: "restore", reason: "This server does not support file attachments." }; + } + const effectiveMaxBytes = clampFileAttachmentUploadBytes(maxBytes); + const oversized = input.fileAttachments.find( + (attachment) => attachment.sizeBytes > effectiveMaxBytes, + ); + return oversized + ? { step: "restore", reason: fileAttachmentTooLargeMessage(oversized.name, effectiveMaxBytes) } + : { step: "send" }; +} + /** * A queued creation can only be dispatched once its payload would pass server * validation; incomplete payloads stay pending until the user edits them. @@ -209,7 +255,7 @@ export function shouldRetryThreadOutboxDelivery(error: unknown): boolean { } export type ThreadOutboxCommandStage = "settings-sync" | "start-turn"; -export type ThreadOutboxFailureAction = "retry" | "discard"; +export type ThreadOutboxFailureAction = "retry" | "restore"; export function resolveThreadOutboxFailureAction(input: { readonly stage: ThreadOutboxCommandStage; @@ -223,5 +269,5 @@ export function resolveThreadOutboxFailureAction(input: { ) { return "retry"; } - return "discard"; + return "restore"; } diff --git a/apps/mobile/src/state/thread-outbox-removal.test.ts b/apps/mobile/src/state/thread-outbox-removal.test.ts new file mode 100644 index 000000000000..e444274ae2c6 --- /dev/null +++ b/apps/mobile/src/state/thread-outbox-removal.test.ts @@ -0,0 +1,305 @@ +import { CommandId, EnvironmentId, MessageId, ProjectId, ThreadId } from "@t3tools/contracts"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; + +const harness = vi.hoisted(() => ({ + cleanup: vi.fn(), + clearDraft: vi.fn(), + flushDrafts: vi.fn(async () => {}), + waitForDrafts: vi.fn( + async () => {}, + ), + manager: null as unknown as ReturnType< + typeof import("./thread-outbox-manager").createThreadOutboxManager + >, +})); + +vi.mock("./thread-outbox", async () => { + const { createThreadOutboxManager } = await import("./thread-outbox-manager"); + const { appAtomRegistry } = await import("./atom-registry"); + harness.manager = createThreadOutboxManager({ + registry: appAtomRegistry, + storage: { + load: async () => [], + write: async () => undefined, + remove: async () => undefined, + }, + }); + return { threadOutboxManager: harness.manager }; +}); + +vi.mock("./use-composer-drafts", async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + clearComposerDraft: harness.clearDraft, + flushComposerDrafts: harness.flushDrafts, + scheduleUnusedComposerAttachmentCleanup: harness.cleanup, + waitForComposerDraftsLoaded: harness.waitForDrafts, + }; +}); + +import { appAtomRegistry } from "./atom-registry"; +import { clearThreadOutboxEnvironment, removeThreadOutboxMessage } from "./thread-outbox-removal"; +import type { QueuedThreadMessage } from "./thread-outbox-model"; +import { composerDraftsAtom } from "./use-composer-drafts"; + +function queuedMessage(input: { + readonly environmentId: string; + readonly messageId: string; + readonly fileUri: string; + readonly creation?: true; +}): QueuedThreadMessage { + return { + environmentId: EnvironmentId.make(input.environmentId), + threadId: ThreadId.make(`thread-${input.messageId}`), + messageId: MessageId.make(input.messageId), + commandId: CommandId.make(`command-${input.messageId}`), + text: "Review the report", + attachments: [ + { + id: `file-${input.messageId}`, + type: "file", + name: "report.pdf", + mimeType: "application/pdf", + sizeBytes: 42, + fileUri: input.fileUri, + }, + ], + ...(input.creation + ? { + creation: { + projectId: ProjectId.make(`project-${input.messageId}`), + workspaceMode: "local" as const, + branch: null, + worktreePath: null, + }, + } + : {}), + createdAt: "2026-08-24T12:00:00.000Z", + }; +} + +afterEach(() => { + appAtomRegistry.set(harness.manager.queuedMessagesByThreadKeyAtom, {}); + appAtomRegistry.set(composerDraftsAtom, {}); + harness.cleanup.mockClear(); + harness.clearDraft.mockClear(); + harness.flushDrafts.mockReset(); + harness.flushDrafts.mockResolvedValue(undefined); + harness.waitForDrafts.mockReset(); + harness.waitForDrafts.mockResolvedValue(undefined); +}); + +describe("thread outbox removal", () => { + it("releases a removed message's attachment files with the removal itself", async () => { + const message = queuedMessage({ + environmentId: "environment-1", + messageId: "message-1", + fileUri: "file:///documents/t3-composer-attachments/report.pdf", + }); + await harness.manager.enqueue(message); + + await removeThreadOutboxMessage(message); + + expect(appAtomRegistry.get(harness.manager.queuedMessagesByThreadKeyAtom)).toEqual({}); + expect(harness.cleanup).toHaveBeenCalledExactlyOnceWith(message.attachments); + }); + + it("keeps an edited message and its files when a revision-checked removal loses", async () => { + const message = queuedMessage({ + environmentId: "environment-1", + messageId: "message-edited", + fileUri: "file:///documents/t3-composer-attachments/report.pdf", + creation: true, + }); + await harness.manager.enqueue(message); + const revision = harness.manager.revisionOf(message.messageId); + const edited = { ...message, text: "edited while restoring" }; + await harness.manager.update(edited); + + await expect(removeThreadOutboxMessage(message, revision)).resolves.toBe(false); + + expect(harness.cleanup).not.toHaveBeenCalled(); + expect(harness.waitForDrafts).not.toHaveBeenCalled(); + expect(harness.clearDraft).not.toHaveBeenCalled(); + expect(harness.flushDrafts).not.toHaveBeenCalled(); + const remaining = Object.values( + appAtomRegistry.get(harness.manager.queuedMessagesByThreadKeyAtom), + ).flat(); + expect(remaining).toEqual([edited]); + }); + + it("clears a removed pending task draft and includes its editor-only files", async () => { + const message = queuedMessage({ + environmentId: "environment-1", + messageId: "message-pending", + fileUri: "file:///documents/t3-composer-attachments/queued.pdf", + creation: true, + }); + const editorOnlyFile = { + id: "file-editor-only", + type: "file" as const, + name: "editor-only.pdf", + mimeType: "application/pdf", + sizeBytes: 84, + fileUri: "file:///documents/t3-composer-attachments/editor-only.pdf", + }; + const draftKey = `pending-task:${message.messageId}`; + appAtomRegistry.set(composerDraftsAtom, { + [draftKey]: { text: "edited", attachments: [editorOnlyFile] }, + }); + await harness.manager.enqueue(message); + + await expect(removeThreadOutboxMessage(message)).resolves.toBe(true); + + expect(harness.waitForDrafts).toHaveBeenCalledOnce(); + expect(harness.clearDraft).toHaveBeenCalledExactlyOnceWith(draftKey, { + deferAttachmentCleanup: true, + }); + expect(harness.flushDrafts).toHaveBeenCalledOnce(); + expect(harness.cleanup).toHaveBeenCalledExactlyOnceWith([ + ...message.attachments, + editorOnlyFile, + ]); + expect(harness.flushDrafts.mock.invocationCallOrder[0]).toBeLessThan( + harness.cleanup.mock.invocationCallOrder[0]!, + ); + }); + + it("does not flush composer drafts when a removed creation has no editor draft", async () => { + const message = queuedMessage({ + environmentId: "environment-1", + messageId: "message-without-editor-draft", + fileUri: "file:///documents/t3-composer-attachments/queued.pdf", + creation: true, + }); + await harness.manager.enqueue(message); + + await expect(removeThreadOutboxMessage(message)).resolves.toBe(true); + + expect(harness.waitForDrafts).toHaveBeenCalledOnce(); + expect(harness.clearDraft).not.toHaveBeenCalled(); + expect(harness.flushDrafts).not.toHaveBeenCalled(); + expect(harness.cleanup).toHaveBeenCalledExactlyOnceWith(message.attachments); + }); + + it("releases only the cleared environment's queued attachment files", async () => { + const cleared = queuedMessage({ + environmentId: "environment-1", + messageId: "message-cleared", + fileUri: "file:///documents/t3-composer-attachments/cleared.pdf", + }); + const kept = queuedMessage({ + environmentId: "environment-2", + messageId: "message-kept", + fileUri: "file:///documents/t3-composer-attachments/kept.pdf", + }); + await harness.manager.enqueue(cleared); + await harness.manager.enqueue(kept); + + await clearThreadOutboxEnvironment(cleared.environmentId); + + expect(harness.cleanup).toHaveBeenCalledExactlyOnceWith(cleared.attachments); + const remaining = Object.values( + appAtomRegistry.get(harness.manager.queuedMessagesByThreadKeyAtom), + ).flat(); + expect(remaining.map((message) => message.messageId)).toEqual([kept.messageId]); + }); + + it("clears only removed pending drafts and keeps drafts for live messages", async () => { + const cleared = queuedMessage({ + environmentId: "environment-1", + messageId: "message-cleared-pending", + fileUri: "file:///documents/t3-composer-attachments/cleared-pending.pdf", + creation: true, + }); + const replaced = queuedMessage({ + environmentId: "environment-1", + messageId: "message-replaced-pending", + fileUri: "file:///documents/t3-composer-attachments/replaced-pending.pdf", + creation: true, + }); + const kept = queuedMessage({ + environmentId: "environment-2", + messageId: "message-kept-pending", + fileUri: "file:///documents/t3-composer-attachments/kept-pending.pdf", + creation: true, + }); + const replacement = { ...replaced, text: "replacement queued while drafts hydrate" }; + const editorOnlyFile = { + id: "file-cleared-editor", + type: "file" as const, + name: "cleared-editor.pdf", + mimeType: "application/pdf", + sizeBytes: 84, + fileUri: "file:///documents/t3-composer-attachments/cleared-editor.pdf", + }; + const hydrationStarted = Promise.withResolvers(); + const hydrationBarrier = Promise.withResolvers(); + harness.waitForDrafts.mockImplementationOnce(async () => { + hydrationStarted.resolve(); + await hydrationBarrier.promise; + }); + const clearedDraftKey = `pending-task:${cleared.messageId}`; + appAtomRegistry.set(composerDraftsAtom, { + [clearedDraftKey]: { text: "edited", attachments: [editorOnlyFile] }, + [`pending-task:${replaced.messageId}`]: { text: "replacement", attachments: [] }, + [`pending-task:${kept.messageId}`]: { text: "other environment", attachments: [] }, + }); + await Promise.all([ + harness.manager.enqueue(cleared), + harness.manager.enqueue(replaced), + harness.manager.enqueue(kept), + ]); + + const clearing = clearThreadOutboxEnvironment(cleared.environmentId); + await hydrationStarted.promise; + const replacing = harness.manager.enqueue(replacement); + hydrationBarrier.resolve(); + await clearing; + await replacing; + + expect(harness.clearDraft).toHaveBeenCalledExactlyOnceWith(clearedDraftKey, { + deferAttachmentCleanup: true, + }); + expect(harness.cleanup).toHaveBeenCalledExactlyOnceWith([ + ...cleared.attachments, + ...replaced.attachments, + editorOnlyFile, + ]); + const remaining = Object.values( + appAtomRegistry.get(harness.manager.queuedMessagesByThreadKeyAtom), + ).flat(); + expect(remaining).toEqual(expect.arrayContaining([replacement, kept])); + }); + + it("keeps removal successful when pending draft persistence fails", async () => { + const message = queuedMessage({ + environmentId: "environment-1", + messageId: "message-draft-flush-fails", + fileUri: "file:///documents/t3-composer-attachments/queued.pdf", + creation: true, + }); + const flushError = new Error("composer storage unavailable"); + const warning = vi.spyOn(console, "warn").mockImplementation(() => {}); + harness.flushDrafts.mockRejectedValueOnce(flushError); + appAtomRegistry.set(composerDraftsAtom, { + [`pending-task:${message.messageId}`]: { text: "edited", attachments: [] }, + }); + await harness.manager.enqueue(message); + + try { + await expect(removeThreadOutboxMessage(message)).resolves.toBe(true); + + expect(harness.clearDraft).toHaveBeenCalledOnce(); + expect(harness.cleanup).not.toHaveBeenCalled(); + expect(warning).toHaveBeenCalledWith( + "[thread-outbox] failed to clean up removed pending task drafts", + flushError, + ); + expect(appAtomRegistry.get(harness.manager.queuedMessagesByThreadKeyAtom)).toEqual({}); + } finally { + warning.mockRestore(); + } + }); +}); diff --git a/apps/mobile/src/state/thread-outbox-removal.ts b/apps/mobile/src/state/thread-outbox-removal.ts new file mode 100644 index 000000000000..d78a0a38b2da --- /dev/null +++ b/apps/mobile/src/state/thread-outbox-removal.ts @@ -0,0 +1,91 @@ +import type { EnvironmentId } from "@t3tools/contracts"; + +import { appAtomRegistry } from "./atom-registry"; +import { threadOutboxManager } from "./thread-outbox"; +import type { QueuedThreadMessage } from "./thread-outbox-model"; +import { + clearComposerDraft, + composerDraftsAtom, + flushComposerDrafts, + scheduleUnusedComposerAttachmentCleanup, + waitForComposerDraftsLoaded, +} from "./use-composer-drafts"; + +async function cleanUpRemovedMessages( + removedMessages: ReadonlyArray, +): Promise { + const attachments = removedMessages.flatMap((message) => message.attachments); + const removedCreations = removedMessages.filter((message) => message.creation !== undefined); + if (removedCreations.length === 0) { + scheduleUnusedComposerAttachmentCleanup(attachments); + return; + } + + try { + await waitForComposerDraftsLoaded(); + const liveMessageIds = new Set( + Object.values(appAtomRegistry.get(threadOutboxManager.queuedMessagesByThreadKeyAtom)) + .flat() + .map((message) => message.messageId), + ); + const drafts = appAtomRegistry.get(composerDraftsAtom); + let clearedDraft = false; + for (const message of removedCreations) { + if (liveMessageIds.has(message.messageId)) { + continue; + } + const draftKey = `pending-task:${message.messageId}`; + const draft = drafts[draftKey]; + if (draft === undefined) { + continue; + } + attachments.push(...draft.attachments); + clearComposerDraft(draftKey, { deferAttachmentCleanup: true }); + clearedDraft = true; + } + if (clearedDraft) { + await flushComposerDrafts(); + } + } catch (error) { + // The outbox removal is already durable. Keep the files and report the + // secondary cleanup failure without changing the successful result. + console.warn("[thread-outbox] failed to clean up removed pending task drafts", error); + return; + } + + scheduleUnusedComposerAttachmentCleanup(attachments); +} + +/** + * The only way a queued message leaves the outbox. Removal also releases the + * message's local attachment files (via the reference-counting sweep, so a + * file still referenced by a draft or another queued message survives). + * Keeping release inside the removal call means no call site can forget it. + * + * `expectedRevision` (from `threadOutboxRevision`) and `canRemove` make the + * removal a compare-and-set: when an edit was accepted or an editor takes the + * message, it stays queued, nothing is released, and this returns false. + */ +export async function removeThreadOutboxMessage( + message: QueuedThreadMessage, + expectedRevision?: number, + canRemove?: () => boolean, +): Promise { + const removed = await threadOutboxManager.remove(message, expectedRevision, canRemove); + if (removed === null) { + return false; + } + // The removed payload, not the caller's snapshot: an accepted update may + // have added files the snapshot never saw. + await cleanUpRemovedMessages([removed]); + return true; +} + +/** Removes every queued message of an environment and releases their files. */ +export async function clearThreadOutboxEnvironment(environmentId: EnvironmentId): Promise { + // clearEnvironment loads and merges persisted messages itself and reports + // what it actually removed, so the release set cannot miss messages a + // failed earlier hydration would have hidden. + const removed = await threadOutboxManager.clearEnvironment(environmentId); + await cleanUpRemovedMessages(removed); +} diff --git a/apps/mobile/src/state/thread-outbox.test.ts b/apps/mobile/src/state/thread-outbox.test.ts index b12ad2dc5843..0069064f3785 100644 --- a/apps/mobile/src/state/thread-outbox.test.ts +++ b/apps/mobile/src/state/thread-outbox.test.ts @@ -16,6 +16,7 @@ import { isQueuedThreadCreationSendable, modelSelectionsEqual, resolveThreadOutboxDeliveryAction, + resolveThreadOutboxDispatchStep, resolveThreadOutboxFailureAction, resolveQueuedThreadSettings, shouldRetryThreadOutboxDelivery, @@ -78,6 +79,29 @@ describe("thread outbox", () => { ).toThrow(); }); + it("persists generic attachment paths without embedding their contents", () => { + const message = { + ...queuedMessage({ + messageId: "message-file", + createdAt: "2026-06-08T10:00:01.000Z", + }), + attachments: [ + { + id: "file-1", + type: "file" as const, + name: "report.pdf", + mimeType: "application/pdf", + sizeBytes: 42, + fileUri: "file:///documents/report.pdf", + uploadedAttachmentId: "pending-report-pdf", + uploadEnvironmentId: EnvironmentId.make("environment-1"), + }, + ], + } satisfies QueuedThreadMessage; + + expect(decodeQueuedThreadMessage(encodeQueuedThreadMessage(message))).toEqual(message); + }); + it("persists the exact selector snapshot while remaining compatible with v1 messages", () => { const legacyMessage = queuedMessage({ messageId: "message-1", @@ -357,6 +381,34 @@ describe("thread outbox", () => { registry.dispose(); }); + it("drops the disk entry when a failed enqueue leaves no queued message behind", async () => { + const registry = AtomRegistry.make(); + const removed: string[] = []; + const manager = createThreadOutboxManager({ + registry, + storage: { + load: async () => [], + write: async () => { + throw new Error("disk full"); + }, + remove: async (message) => { + removed.push(message.messageId); + }, + }, + }); + const message = queuedMessage({ + messageId: "message-1", + createdAt: "2026-06-08T10:00:01.000Z", + }); + + // A concurrent update losing its race can compensate-write this payload + // to disk before this write fails; rollback must clear that copy or a + // restart resurrects the message. + await expect(manager.enqueue(message)).rejects.toBeInstanceOf(ThreadOutboxManagerError); + expect(removed).toEqual(["message-1"]); + registry.dispose(); + }); + it("keeps a same-id retry queued when the first attempt's write fails", async () => { const registry = AtomRegistry.make(); let failNextWrite = true; @@ -457,6 +509,445 @@ describe("thread outbox", () => { registry.dispose(); }); + it("rejects a stale revision before its payload reaches durable storage", async () => { + const registry = AtomRegistry.make(); + const writes: string[] = []; + const manager = createThreadOutboxManager({ + registry, + storage: { + load: async () => [], + write: async (message) => { + writes.push(message.text); + }, + remove: async () => undefined, + }, + }); + const original = queuedMessage({ + messageId: "message-edit-race", + createdAt: "2026-06-08T10:00:01.000Z", + }); + const edited = { ...original, text: "keep my changes" }; + + await manager.enqueue(original); + // Revision captured before slow work (an attachment upload) starts. + const revision = manager.revisionOf(original.messageId); + await manager.update(edited); + + await expect(manager.update({ ...original, text: "stale upload" }, revision)).resolves.toBe( + false, + ); + // The losing writer was rejected before persisting: no stale payload can + // sit on disk waiting to resurrect on the next load. + expect(writes).toEqual([original.text, "keep my changes"]); + expect(registry.get(manager.queuedMessagesByThreadKeyAtom)).toEqual({ + "environment-1:thread-1": [edited], + }); + registry.dispose(); + }); + + it("does not publish a stale attachment update after a replacement appears during its write", async () => { + const registry = AtomRegistry.make(); + const writes: string[] = []; + let resumeWrite: () => void = () => {}; + let signalWriteStarted: () => void = () => {}; + const writeStarted = new Promise((resolve) => { + signalWriteStarted = resolve; + }); + const writeBarrier = new Promise((resolve) => { + resumeWrite = resolve; + }); + const manager = createThreadOutboxManager({ + registry, + storage: { + load: async () => [], + write: async (message) => { + writes.push(message.text); + if (message.text === "stale upload") { + signalWriteStarted(); + await writeBarrier; + } + }, + remove: async () => undefined, + }, + }); + const original = queuedMessage({ + messageId: "message-write-race", + createdAt: "2026-06-08T10:00:01.000Z", + }); + const replacement = { ...original, text: "newer edit" }; + + await manager.enqueue(original); + const update = manager.update( + { ...original, text: "stale upload" }, + manager.revisionOf(original.messageId), + ); + await writeStarted; + const enqueue = manager.enqueue(replacement); + resumeWrite(); + + await expect(update).resolves.toBe(false); + // The losing update re-writes the winning payload inside its own + // mutation, before the replacement's serialized write lands, so a crash + // between the two cannot leave the stale payload on disk. + expect(writes).toEqual([original.text, "stale upload", "newer edit", "newer edit"]); + await enqueue; + expect(registry.get(manager.queuedMessagesByThreadKeyAtom)).toEqual({ + "environment-1:thread-1": [replacement], + }); + registry.dispose(); + }); + + it("refuses to remove a message that was rewritten after the removal decision", async () => { + const registry = AtomRegistry.make(); + const stored = new Map(); + const manager = createThreadOutboxManager({ + registry, + storage: { + load: async () => [...stored.values()], + write: async (message) => { + stored.set(message.messageId, message); + }, + remove: async (message) => { + stored.delete(message.messageId); + }, + }, + }); + const original = queuedMessage({ + messageId: "message-remove-race", + createdAt: "2026-06-08T10:00:01.000Z", + }); + const edited = { ...original, text: "edited while restoring" }; + + await manager.enqueue(original); + // Revision captured when restore-to-composer read the payload it intends + // to remove; the edit accepted afterwards must survive the removal. + const revision = manager.revisionOf(original.messageId); + await manager.update(edited); + + await expect(manager.remove(original, revision)).resolves.toBe(null); + expect(registry.get(manager.queuedMessagesByThreadKeyAtom)).toEqual({ + "environment-1:thread-1": [edited], + }); + expect(stored.get(original.messageId)).toEqual(edited); + + await expect(manager.remove(edited, manager.revisionOf(edited.messageId))).resolves.toEqual( + edited, + ); + expect(registry.get(manager.queuedMessagesByThreadKeyAtom)).toEqual({}); + registry.dispose(); + }); + + it("keeps a retry enqueued when its publish races a revision-checked removal", async () => { + const registry = AtomRegistry.make(); + const stored = new Map(); + const removeStarted = Promise.withResolvers(); + const removeBarrier = Promise.withResolvers(); + const replacementWriteStarted = Promise.withResolvers(); + const replacementWriteBarrier = Promise.withResolvers(); + const original = queuedMessage({ + messageId: "message-remove-enqueue-race", + createdAt: "2026-06-08T10:00:01.000Z", + }); + const retried = { ...original, text: "retried" }; + const manager = createThreadOutboxManager({ + registry, + storage: { + load: async () => [...stored.values()], + write: async (message) => { + if (message === retried) { + replacementWriteStarted.resolve(); + await replacementWriteBarrier.promise; + } + stored.set(message.messageId, message); + }, + remove: async (message) => { + removeStarted.resolve(); + await removeBarrier.promise; + stored.delete(message.messageId); + }, + }, + }); + + await manager.enqueue(original); + const removal = manager.remove(original, manager.revisionOf(original.messageId)); + let removalSettled = false; + void removal.then(() => { + removalSettled = true; + }); + await removeStarted.promise; + // Published synchronously while the durable remove is still in flight. + const enqueue = manager.enqueue(retried); + removeBarrier.resolve(); + await replacementWriteStarted.promise; + + // The canceled removal itself restores the durable winner. The queued + // enqueue write has not had a chance to run yet. + expect(removalSettled).toBe(false); + replacementWriteBarrier.resolve(); + await expect(removal).resolves.toBe(null); + expect(stored.get(original.messageId)).toEqual(retried); + await enqueue; + expect(registry.get(manager.queuedMessagesByThreadKeyAtom)).toEqual({ + "environment-1:thread-1": [retried], + }); + expect(stored.get(original.messageId)).toEqual(retried); + registry.dispose(); + }); + + it("restores a message when its live removal predicate changes during storage removal", async () => { + const registry = AtomRegistry.make(); + const stored = new Map(); + const removeStarted = Promise.withResolvers(); + const removeBarrier = Promise.withResolvers(); + const manager = createThreadOutboxManager({ + registry, + storage: { + load: async () => [...stored.values()], + write: async (message) => { + stored.set(message.messageId, message); + }, + remove: async (message) => { + removeStarted.resolve(); + await removeBarrier.promise; + stored.delete(message.messageId); + }, + }, + }); + const message = queuedMessage({ + messageId: "message-remove-predicate-race", + createdAt: "2026-06-08T10:00:01.000Z", + }); + let canRemove = true; + + await manager.enqueue(message); + const removal = manager.remove(message, manager.revisionOf(message.messageId), () => canRemove); + await removeStarted.promise; + canRemove = false; + removeBarrier.resolve(); + + await expect(removal).resolves.toBe(null); + expect(registry.get(manager.queuedMessagesByThreadKeyAtom)).toEqual({ + "environment-1:thread-1": [message], + }); + expect(stored.get(message.messageId)).toEqual(message); + registry.dispose(); + }); + + it("preserves concurrent enqueues while clearing an environment", async () => { + const registry = AtomRegistry.make(); + const stored = new Map(); + const removeStarted = Promise.withResolvers(); + const removeBarrier = Promise.withResolvers(); + const manager = createThreadOutboxManager({ + registry, + storage: { + load: async () => [...stored.values()], + write: async (message) => { + stored.set(message.messageId, message); + }, + remove: async (message) => { + if (message.environmentId === EnvironmentId.make("environment-clear")) { + removeStarted.resolve(); + await removeBarrier.promise; + } + stored.delete(message.messageId); + }, + }, + }); + const replaced = queuedMessage({ + environmentId: "environment-clear", + messageId: "message-replaced-during-clear", + createdAt: "2026-06-08T10:00:01.000Z", + }); + const removed = queuedMessage({ + environmentId: "environment-clear", + messageId: "message-removed-by-clear", + createdAt: "2026-06-08T10:00:02.000Z", + }); + const kept = queuedMessage({ + environmentId: "environment-keep", + messageId: "message-other-environment", + createdAt: "2026-06-08T10:00:03.000Z", + }); + const replacement = { ...replaced, text: "replacement" }; + const added = queuedMessage({ + environmentId: "environment-clear", + messageId: "message-added-during-clear", + createdAt: "2026-06-08T10:00:04.000Z", + }); + + await Promise.all([manager.enqueue(replaced), manager.enqueue(removed), manager.enqueue(kept)]); + const clearing = manager.clearEnvironment(replaced.environmentId); + await removeStarted.promise; + const replacing = manager.enqueue(replacement); + const adding = manager.enqueue(added); + removeBarrier.resolve(); + + await expect(clearing).resolves.toEqual([removed]); + expect(registry.get(manager.queuedMessagesByThreadKeyAtom)).toEqual({ + "environment-clear:thread-1": [replacement, added], + "environment-keep:thread-1": [kept], + }); + expect(stored.get(replacement.messageId)).toEqual(replacement); + expect(stored.has(removed.messageId)).toBe(false); + + await Promise.all([replacing, adding]); + expect([...stored.values()]).toEqual(expect.arrayContaining([replacement, added, kept])); + registry.dispose(); + }); + + it("does not restore a message removed before a queued environment clear starts", async () => { + const registry = AtomRegistry.make(); + const stored = new Map(); + const removeStarted = Promise.withResolvers(); + const removeBarrier = Promise.withResolvers(); + let removeCalls = 0; + const manager = createThreadOutboxManager({ + registry, + storage: { + load: async () => [...stored.values()], + write: async (message) => { + stored.set(message.messageId, message); + }, + remove: async (message) => { + removeCalls += 1; + if (removeCalls === 1) { + removeStarted.resolve(); + await removeBarrier.promise; + } + stored.delete(message.messageId); + }, + }, + }); + const message = queuedMessage({ + environmentId: "environment-clear", + messageId: "message-removed-before-clear", + createdAt: "2026-06-08T10:00:01.000Z", + }); + + await manager.enqueue(message); + const removal = manager.remove(message); + await removeStarted.promise; + const clearing = manager.clearEnvironment(message.environmentId); + removeBarrier.resolve(); + + await expect(removal).resolves.toEqual(message); + await expect(clearing).resolves.toEqual([]); + expect(removeCalls).toBe(1); + expect(registry.get(manager.queuedMessagesByThreadKeyAtom)).toEqual({}); + expect(stored.has(message.messageId)).toBe(false); + registry.dispose(); + }); + + it("keeps an enqueue published while an environment clear waits to start", async () => { + const registry = AtomRegistry.make(); + const stored = new Map(); + const mutationStarted = Promise.withResolvers(); + const mutationBarrier = Promise.withResolvers(); + let removeCalls = 0; + const manager = createThreadOutboxManager({ + registry, + storage: { + load: async () => [...stored.values()], + write: async (message) => { + stored.set(message.messageId, message); + }, + remove: async () => { + removeCalls += 1; + }, + }, + }); + const blocker = manager.serialize(async () => { + mutationStarted.resolve(); + await mutationBarrier.promise; + }); + await mutationStarted.promise; + const clearing = manager.clearEnvironment(EnvironmentId.make("environment-clear")); + const added = queuedMessage({ + environmentId: "environment-clear", + messageId: "message-enqueued-before-clear-start", + createdAt: "2026-06-08T10:00:01.000Z", + }); + const enqueue = manager.enqueue(added); + mutationBarrier.resolve(); + + await blocker; + await expect(clearing).resolves.toEqual([]); + expect(removeCalls).toBe(0); + expect(registry.get(manager.queuedMessagesByThreadKeyAtom)).toEqual({ + "environment-clear:thread-1": [added], + }); + await enqueue; + expect(stored.get(added.messageId)).toEqual(added); + registry.dispose(); + }); + + it("removes an already-created pending task before the file-capability gate runs", () => { + // The creation's startTurn already made the thread, so the resolver wants + // the queued message removed. A missing server config (or missing file + // support) must not turn that into a restore, which would duplicate the + // task as a draft. + const fileAttachments = [{ name: "report.pdf", sizeBytes: 42 }]; + expect( + resolveThreadOutboxDispatchStep({ + deliveryAction: "remove", + fileAttachments, + serverConfig: null, + }), + ).toEqual({ step: "remove" }); + expect( + resolveThreadOutboxDispatchStep({ + deliveryAction: "remove", + fileAttachments, + serverConfig: { maxFileUploadBytes: undefined }, + }), + ).toEqual({ step: "remove" }); + }); + + it("retries instead of parking a file message while the server config loads", () => { + expect( + resolveThreadOutboxDispatchStep({ + deliveryAction: "send", + fileAttachments: [{ name: "report.pdf", sizeBytes: 42 }], + serverConfig: null, + }), + ).toEqual({ step: "retry" }); + }); + + it("gates a sending file message on the server's file support and limit", () => { + expect( + resolveThreadOutboxDispatchStep({ + deliveryAction: "send", + fileAttachments: [{ name: "report.pdf", sizeBytes: 42 }], + serverConfig: { maxFileUploadBytes: undefined }, + }), + ).toEqual({ step: "restore", reason: "This server does not support file attachments." }); + expect( + resolveThreadOutboxDispatchStep({ + deliveryAction: "send", + fileAttachments: [{ name: "big.zip", sizeBytes: 2 * 1024 * 1024 }], + serverConfig: { maxFileUploadBytes: 1024 * 1024 }, + }), + ).toEqual({ step: "restore", reason: "'big.zip' exceeds the 1 MB attachment limit." }); + expect( + resolveThreadOutboxDispatchStep({ + deliveryAction: "send", + fileAttachments: [{ name: "report.pdf", sizeBytes: 42 }], + serverConfig: { maxFileUploadBytes: 1024 * 1024 }, + }), + ).toEqual({ step: "send" }); + }); + + it("sends a message without file attachments before the server config loads", () => { + expect( + resolveThreadOutboxDispatchStep({ + deliveryAction: "send", + fileAttachments: [], + serverConfig: null, + }), + ).toEqual({ step: "send" }); + }); + it("only removes a missing-thread message after shell synchronization is live", () => { expect( resolveThreadOutboxDeliveryAction({ @@ -618,6 +1109,6 @@ describe("thread outbox", () => { error: deterministicFailure, interrupted: false, }), - ).toBe("discard"); + ).toBe("restore"); }); }); diff --git a/apps/mobile/src/state/thread-outbox.ts b/apps/mobile/src/state/thread-outbox.ts index 1de1f8da655c..2f9d8c85416c 100644 --- a/apps/mobile/src/state/thread-outbox.ts +++ b/apps/mobile/src/state/thread-outbox.ts @@ -1,5 +1,3 @@ -import type { EnvironmentId } from "@t3tools/contracts"; - import { appAtomRegistry } from "./atom-registry"; import { createThreadOutboxManager } from "./thread-outbox-manager"; import type { QueuedThreadMessage } from "./thread-outbox-model"; @@ -36,15 +34,23 @@ export function confirmThreadOutboxMessageQueued(message: QueuedThreadMessage): return threadOutboxManager.confirmQueued(message); } -/** Rewrite a queued message; no-op (false) if it was removed in the meantime. */ -export function updateThreadOutboxMessage(message: QueuedThreadMessage): Promise { - return threadOutboxManager.update(message); +/** + * Rewrite a queued message; no-op (false) if it was removed in the meantime, + * or (with `expectedRevision` from `threadOutboxRevision`) if any other write + * was accepted since the revision was read. + */ +export function updateThreadOutboxMessage( + message: QueuedThreadMessage, + expectedRevision?: number, +): Promise { + return threadOutboxManager.update(message, expectedRevision); } -export function removeThreadOutboxMessage(message: QueuedThreadMessage): Promise { - return threadOutboxManager.remove(message); +/** Snapshot of a queued message's write revision, for update's CAS. */ +export function threadOutboxRevision(messageId: QueuedThreadMessage["messageId"]): number { + return threadOutboxManager.revisionOf(messageId); } -export function clearThreadOutboxEnvironment(environmentId: EnvironmentId): Promise { - return threadOutboxManager.clearEnvironment(environmentId); -} +// Removal lives in `thread-outbox-removal.ts`: taking a message out of the +// outbox must also release its local attachment files, and that owner needs +// the composer draft state this module must not depend on. diff --git a/apps/mobile/src/state/thread-pr-presentation.ts b/apps/mobile/src/state/thread-pr-presentation.ts index 76d57d55796c..ab8e5a20f009 100644 --- a/apps/mobile/src/state/thread-pr-presentation.ts +++ b/apps/mobile/src/state/thread-pr-presentation.ts @@ -17,9 +17,9 @@ export interface ThreadPrPresentation { } const PR_STATE_TEXT_CLASS: Record = { - open: "text-emerald-600 dark:text-emerald-400", - merged: "text-violet-600 dark:text-violet-400", - closed: "text-zinc-500 dark:text-zinc-400", + open: "text-adaptive-emerald-600-400", + merged: "text-adaptive-violet-600-400", + closed: "text-adaptive-zinc-500-400", }; export function presentThreadPr( diff --git a/apps/mobile/src/state/use-atom-query-runner.ts b/apps/mobile/src/state/use-atom-query-runner.ts index 22f971e09a5d..691b1f43cb87 100644 --- a/apps/mobile/src/state/use-atom-query-runner.ts +++ b/apps/mobile/src/state/use-atom-query-runner.ts @@ -1,7 +1,7 @@ import { RegistryContext } from "@effect/atom-react"; import { executeAtomQuery, - type AtomCommandOptions, + type AtomQueryOptions, type AtomCommandResult, } from "@t3tools/client-runtime/state/runtime"; import { AsyncResult, type Atom } from "effect/unstable/reactivity"; @@ -9,12 +9,13 @@ import { useCallback, useContext } from "react"; export function useAtomQueryRunner( family: (target: T) => Atom.Atom>, - options?: string | AtomCommandOptions, + options?: string | AtomQueryOptions, ): (target: T) => Promise> { const registry = useContext(RegistryContext); const explicitLabel = typeof options === "string" ? options : options?.label; const reportFailure = typeof options === "string" ? true : (options?.reportFailure ?? true); const reportDefect = typeof options === "string" ? true : (options?.reportDefect ?? true); + const refresh = typeof options === "string" ? false : (options?.refresh ?? false); return useCallback( (target: T) => { @@ -23,8 +24,9 @@ export function useAtomQueryRunner( label: explicitLabel ?? atom.label?.[0] ?? "atom query", reportFailure, reportDefect, + refresh, }); }, - [explicitLabel, family, registry, reportDefect, reportFailure], + [explicitLabel, family, registry, refresh, reportDefect, reportFailure], ); } diff --git a/apps/mobile/src/state/use-composer-drafts.test.ts b/apps/mobile/src/state/use-composer-drafts.test.ts index 8dbddfe1fece..c5c6ca69f3c0 100644 --- a/apps/mobile/src/state/use-composer-drafts.test.ts +++ b/apps/mobile/src/state/use-composer-drafts.test.ts @@ -1,12 +1,21 @@ import { afterEach, describe, expect, it } from "@effect/vitest"; -import { EnvironmentId, ProviderInstanceId } from "@t3tools/contracts"; -import { vi } from "vite-plus/test"; +import { + CommandId, + EnvironmentId, + MessageId, + ProviderInstanceId, + ThreadId, +} from "@t3tools/contracts"; +import { onTestFinished, vi } from "vite-plus/test"; const composerDraftFileMocks = vi.hoisted(() => { let document = ""; let writeError: Error | null = null; let releaseRead: (() => void) | null = null; let readBarrier = Promise.resolve(); + let nextWriteBarrier: Promise | null = null; + let onWrite: (() => void) | null = null; + const writes: string[] = []; return { blockRead() { @@ -27,6 +36,18 @@ const composerDraftFileMocks = vi.hoisted(() => { setWriteError(error: Error | null) { writeError = error; }, + setNextWriteBarrier(barrier: Promise | null) { + nextWriteBarrier = barrier; + }, + setOnWrite(callback: (() => void) | null) { + onWrite = callback; + }, + getWrites(): ReadonlyArray { + return writes; + }, + resetWrites() { + writes.length = 0; + }, Directory: class { create() {} }, @@ -47,33 +68,84 @@ const composerDraftFileMocks = vi.hoisted(() => { if (writeError) { throw writeError; } + if (nextWriteBarrier) { + const barrier = nextWriteBarrier; + nextWriteBarrier = null; + return barrier.then(() => { + document = value; + writes.push(value); + onWrite?.(); + }); + } document = value; + writes.push(value); + onWrite?.(); } }, }; }); +const composerAttachmentCleanupMocks = vi.hoisted(() => ({ + remove: vi.fn(async () => undefined), + releaseUploads: vi.fn(async () => undefined), +})); + +const incomingShareStorageMocks = vi.hoisted(() => ({ + load: vi.fn( + async () => [], + ), +})); + vi.mock("expo-file-system", () => ({ Directory: composerDraftFileMocks.Directory, File: composerDraftFileMocks.File, Paths: { document: "/documents" }, })); +vi.mock("../lib/composerImages", () => ({ + removePersistedComposerAttachmentFile: composerAttachmentCleanupMocks.remove, +})); + +vi.mock("../lib/attachmentUpload", () => ({ + releasePendingAttachmentUploads: composerAttachmentCleanupMocks.releaseUploads, +})); + +vi.mock("../features/sharing/incoming-share-storage", () => ({ + loadIncomingShareDrafts: incomingShareStorageMocks.load, +})); + import { appAtomRegistry } from "./atom-registry"; +import { threadOutboxManager } from "./thread-outbox"; import { + appendComposerDraftAttachments, + archiveCloudComposerDrafts, clearComposerDraftContentState, + clearComposerDraftsEnvironment, ComposerDraftPersistenceError, composerDraftsAtom, + composerCloudDraftsAtom, copyComposerDraftContentIfEmpty, copyComposerDraftContentState, + decodePersistedComposerState, decodePersistedComposerDrafts, + ensureComposerDraftsLoaded, type ComposerDraft, flushComposerDrafts, getComposerDraftSnapshot, mergeComposerDraftContentState, + releaseUnusedComposerAttachmentFiles, removeComposerDraftsForEnvironment, + resetComposerDraftsLoadState, + retainComposerAttachmentFileForPreview, restoreComposerDraftSnapshotState, + restoreCloudComposerDrafts, setComposerDraftText, + setComposerDraftAttachmentUpload, + waitForComposerDraftsLoaded, + setStickyComposerModelSelection, + stickyComposerModelSelectionAtom, + undoComposerDraftMerge, + undoComposerDraftMergeState, } from "./use-composer-drafts"; const DRAFT: ComposerDraft = { @@ -82,10 +154,688 @@ const DRAFT: ComposerDraft = { }; afterEach(() => { + vi.useRealTimers(); + resetComposerDraftsLoadState(); + composerDraftFileMocks.setDocument(""); + composerDraftFileMocks.setWriteError(null); + composerDraftFileMocks.setNextWriteBarrier(null); + composerDraftFileMocks.setOnWrite(null); + composerDraftFileMocks.resetWrites(); appAtomRegistry.set(composerDraftsAtom, {}); + appAtomRegistry.set(composerCloudDraftsAtom, { accountId: null, signedOut: {} }); + appAtomRegistry.set(stickyComposerModelSelectionAtom, null); + appAtomRegistry.set(threadOutboxManager.queuedMessagesByThreadKeyAtom, {}); + composerAttachmentCleanupMocks.remove.mockClear(); + composerAttachmentCleanupMocks.releaseUploads.mockReset(); + composerAttachmentCleanupMocks.releaseUploads.mockResolvedValue(undefined); + incomingShareStorageMocks.load.mockReset(); + incomingShareStorageMocks.load.mockResolvedValue([]); }); describe("mobile composer drafts", () => { + // Hydration is one-shot per module instance and the attachment sweep now + // triggers it too, so this test must observe it before any sweep test runs. + it("waits for persisted drafts before copying content between projects", async () => { + const sourceKey = "new-task:environment-1:project-1"; + const targetKey = "new-task:environment-1:project-2"; + const unrelatedKey = "environment-1:thread-1"; + const source = { text: "Current task", attachments: [] } satisfies ComposerDraft; + const target = { text: "Persisted target", attachments: [] } satisfies ComposerDraft; + const unrelated = { text: "Keep me", attachments: [] } satisfies ComposerDraft; + + composerDraftFileMocks.setDocument({ + schemaVersion: 1, + drafts: { + [targetKey]: target, + [unrelatedKey]: unrelated, + }, + }); + composerDraftFileMocks.blockRead(); + appAtomRegistry.set(composerDraftsAtom, { [sourceKey]: source }); + + const copy = copyComposerDraftContentIfEmpty(sourceKey, targetKey); + expect(appAtomRegistry.get(composerDraftsAtom)).toEqual({ [sourceKey]: source }); + + composerDraftFileMocks.releaseRead(); + await copy; + + expect(appAtomRegistry.get(composerDraftsAtom)).toEqual({ + [sourceKey]: source, + [targetKey]: target, + [unrelatedKey]: unrelated, + }); + }); + + it("hydrates generic file attachments from their saved local paths", () => { + const file = { + id: "file-1", + type: "file" as const, + name: "report.pdf", + mimeType: "application/pdf", + sizeBytes: 42, + fileUri: "file:///documents/report.pdf", + }; + + expect( + decodePersistedComposerDrafts({ + schemaVersion: 1, + drafts: { + "environment-1:thread-1": { text: "Review this file", attachments: [file] }, + }, + }), + ).toEqual({ + "environment-1:thread-1": { text: "Review this file", attachments: [file] }, + }); + }); + + it("releases videos rejected by the live draft limit and keeps accepted files", async () => { + const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true); + onTestFinished(() => outboxLoad.mockRestore()); + const cleanup = Promise.withResolvers(); + composerAttachmentCleanupMocks.remove.mockImplementationOnce(async () => { + cleanup.resolve(); + }); + const makeAttachment = (id: string) => ({ + id, + type: "file" as const, + name: `${id}.mov`, + mimeType: "video/quicktime", + sizeBytes: 42, + fileUri: `file:///documents/t3-composer-attachments/${id}.mov`, + }); + const draftKey = "new-task:environment-1:project-cap"; + const existing = Array.from({ length: 7 }, (_, index) => makeAttachment(`held-${index}`)); + appAtomRegistry.set(composerDraftsAtom, { + [draftKey]: { text: "send this", attachments: existing }, + }); + + const rejected = appendComposerDraftAttachments(draftKey, [ + makeAttachment("incoming-1"), + makeAttachment("incoming-2"), + ]); + + expect(rejected).toBe(1); + const draft = appAtomRegistry.get(composerDraftsAtom)[draftKey]; + expect(draft?.attachments).toHaveLength(8); + expect(draft?.attachments.at(-1)?.id).toBe("incoming-1"); + await cleanup.promise; + expect(composerAttachmentCleanupMocks.remove).toHaveBeenCalledExactlyOnceWith( + makeAttachment("incoming-2").fileUri, + ); + + // Restore paths bypass the cap so a failed send never drops its files. + const overflowRejected = appendComposerDraftAttachments( + draftKey, + [makeAttachment("restored-1")], + { allowOverflow: true }, + ); + expect(overflowRejected).toBe(0); + expect(appAtomRegistry.get(composerDraftsAtom)[draftKey]?.attachments).toHaveLength(9); + }); + + it("keeps shared attachment files until every draft releases them", async () => { + const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true); + onTestFinished(() => outboxLoad.mockRestore()); + const file = { + id: "file-1", + type: "file" as const, + name: "report.pdf", + mimeType: "application/pdf", + sizeBytes: 42, + fileUri: "file:///documents/t3-composer-attachments/report.pdf", + }; + appAtomRegistry.set(composerDraftsAtom, { + source: { text: "First draft", attachments: [file] }, + copied: { text: "Second draft", attachments: [file] }, + }); + + await releaseUnusedComposerAttachmentFiles([file]); + expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled(); + + appAtomRegistry.set(composerDraftsAtom, { + copied: { text: "Second draft", attachments: [file] }, + }); + await releaseUnusedComposerAttachmentFiles([file]); + expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled(); + + appAtomRegistry.set(composerDraftsAtom, {}); + await releaseUnusedComposerAttachmentFiles([file]); + expect(composerAttachmentCleanupMocks.remove).toHaveBeenCalledWith(file.fileUri); + }); + + it("keeps a failed-send draft's pending upload for retry", async () => { + const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true); + onTestFinished(() => outboxLoad.mockRestore()); + const file = { + id: "file-failed-send", + type: "file" as const, + name: "report.pdf", + mimeType: "application/pdf", + sizeBytes: 42, + fileUri: "file:///documents/t3-composer-attachments/failed-send.pdf", + uploadedAttachmentId: "pending-failed-send", + uploadEnvironmentId: EnvironmentId.make("environment-1"), + }; + appAtomRegistry.set(composerDraftsAtom, { + "environment-1:thread-1": { text: "Retry this send", attachments: [file] }, + }); + + await releaseUnusedComposerAttachmentFiles([file]); + + expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled(); + expect(composerAttachmentCleanupMocks.releaseUploads).not.toHaveBeenCalled(); + }); + + it("retains offline image bytes and newer edits when an early upload finishes", async () => { + const key = "environment-1:thread-1"; + const image = { + id: "photo", + type: "image" as const, + name: "photo.png", + mimeType: "image/png", + sizeBytes: 3, + dataUrl: "data:image/png;base64,YWJj", + previewUri: "file:///photo.png", + }; + const second = { ...image, id: "second", name: "second.png" }; + const uploaded = { + ...image, + uploadedAttachmentId: "pending-photo", + uploadEnvironmentId: EnvironmentId.make("environment-1"), + }; + composerDraftFileMocks.setDocument({ schemaVersion: 1, drafts: {} }); + appendComposerDraftAttachments(key, [image]); + setComposerDraftText(key, "Edited while uploading"); + appendComposerDraftAttachments(key, [second]); + expect(setComposerDraftAttachmentUpload(key, uploaded)).toBe(true); + await flushComposerDrafts(); + + appAtomRegistry.set(composerDraftsAtom, {}); + resetComposerDraftsLoadState(); + await waitForComposerDraftsLoaded(); + expect(getComposerDraftSnapshot(key)).toMatchObject({ + text: "Edited while uploading", + attachments: [uploaded, second], + }); + expect(setComposerDraftAttachmentUpload(key, { ...uploaded, id: "removed-photo" })).toBe(false); + expect(getComposerDraftSnapshot(key).attachments).toHaveLength(2); + }); + + it("cleans up an unreferenced image upload even when there is no local file URI", async () => { + const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true); + onTestFinished(() => outboxLoad.mockRestore()); + const environmentId = EnvironmentId.make("environment-1"); + await releaseUnusedComposerAttachmentFiles([ + { + id: "photo", + type: "image", + name: "photo.png", + mimeType: "image/png", + sizeBytes: 3, + dataUrl: "data:image/png;base64,YWJj", + previewUri: "file:///photo.png", + uploadedAttachmentId: "pending-photo", + uploadEnvironmentId: environmentId, + }, + ]); + expect(composerAttachmentCleanupMocks.releaseUploads).toHaveBeenCalledWith(environmentId, [ + "pending-photo", + ]); + expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled(); + }); + + it("keeps signed-out files through cleanup and restart, and restores only the owning account", async () => { + const load = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true); + onTestFinished(() => load.mockRestore()); + await waitForComposerDraftsLoaded(); + const environmentId = EnvironmentId.make("cloud-environment"); + const key = `${environmentId}:thread-1`; + const file = { + id: "local-pdf", + type: "file" as const, + name: "notes.pdf", + mimeType: "application/pdf", + sizeBytes: 42, + fileUri: "file:///documents/t3-composer-attachments/notes.pdf", + uploadEnvironmentId: environmentId, + uploadedAttachmentId: "pending-pdf", + }; + const queued = { + environmentId, + threadId: ThreadId.make("thread-2"), + messageId: MessageId.make("queued-1"), + commandId: CommandId.make("command-1"), + text: "Send later", + attachments: [file], + createdAt: "2026-08-31T12:00:00.000Z", + }; + appAtomRegistry.set(composerDraftsAtom, { + [key]: { text: "Unsent notes", attachments: [file] }, + "direct-environment:thread-1": DRAFT, + "pending-task:queued-1": { text: "Edited queued task", attachments: [file] }, + }); + appAtomRegistry.set(threadOutboxManager.queuedMessagesByThreadKeyAtom, { queued: [queued] }); + await archiveCloudComposerDrafts("account-a", new Set([environmentId])); + expect(appAtomRegistry.get(composerDraftsAtom)).toEqual({ + "direct-environment:thread-1": DRAFT, + }); + // The registry can remove the active outbox and drafts after the backup lands. + appAtomRegistry.set(threadOutboxManager.queuedMessagesByThreadKeyAtom, {}); + await clearComposerDraftsEnvironment(environmentId); + await releaseUnusedComposerAttachmentFiles([file]); + expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled(); + expect(composerAttachmentCleanupMocks.releaseUploads).not.toHaveBeenCalled(); + + appAtomRegistry.set(composerDraftsAtom, {}); + appAtomRegistry.set(composerCloudDraftsAtom, { accountId: null, signedOut: {} }); + resetComposerDraftsLoadState(); + await waitForComposerDraftsLoaded(); + await restoreCloudComposerDrafts("account-b"); + expect(getComposerDraftSnapshot(key).attachments).toEqual([]); + expect(appAtomRegistry.get(threadOutboxManager.queuedMessagesByThreadKeyAtom)).toEqual({}); + const enqueue = vi.spyOn(threadOutboxManager, "enqueue").mockResolvedValue(); + onTestFinished(() => enqueue.mockRestore()); + await restoreCloudComposerDrafts("account-a"); + expect(getComposerDraftSnapshot(key)).toEqual({ text: "Unsent notes", attachments: [file] }); + expect(getComposerDraftSnapshot("pending-task:queued-1").text).toBe("Edited queued task"); + expect(enqueue).toHaveBeenCalledExactlyOnceWith(queued); + expect(appAtomRegistry.get(composerCloudDraftsAtom).signedOut).toEqual({}); + const persisted = decodePersistedComposerState( + JSON.parse(composerDraftFileMocks.getDocument()), + ); + expect(persisted.drafts[key]?.attachments).toEqual([file]); + expect(persisted.cloudDrafts.accountId).toBe("account-a"); + }); + + it("fails sign-out preservation before cleanup if a durable backup cannot be written", async () => { + const load = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true); + onTestFinished(() => load.mockRestore()); + await waitForComposerDraftsLoaded(); + appAtomRegistry.set(composerDraftsAtom, { "environment-1:thread-1": DRAFT }); + composerDraftFileMocks.setWriteError(new Error("Storage is full")); + await expect( + archiveCloudComposerDrafts("account-a", new Set([EnvironmentId.make("environment-1")])), + ).rejects.toThrow(); + expect( + appAtomRegistry.get(composerCloudDraftsAtom).signedOut["account-a"]?.drafts[ + "environment-1:thread-1" + ], + ).toEqual(DRAFT); + expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled(); + composerDraftFileMocks.setWriteError(null); + await archiveCloudComposerDrafts(null, new Set([EnvironmentId.make("environment-1")])); + expect( + decodePersistedComposerState(JSON.parse(composerDraftFileMocks.getDocument())).cloudDrafts + .signedOut["account-a"]?.drafts["environment-1:thread-1"], + ).toEqual(DRAFT); + }); + + it("keeps a removed file until both playback and a share copy finish", async () => { + const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true); + onTestFinished(() => outboxLoad.mockRestore()); + const fileName = "33333333-3333-4333-8333-333333333333-recording.mp4"; + const file = { + id: "file-preview", + type: "file" as const, + name: "recording.mp4", + mimeType: "video/mp4", + sizeBytes: 42, + fileUri: `file:///private/var/mobile/Containers/Data/Application/11111111-1111-4111-8111-111111111111/Documents/t3-composer-attachments/${fileName}`, + }; + const currentFile = { + ...file, + fileUri: `file:///var/mobile/Containers/Data/Application/22222222-2222-4222-8222-222222222222/Documents/t3-composer-attachments/${fileName}`, + }; + const releasePlayback = retainComposerAttachmentFileForPreview(file); + const releaseShareCopy = retainComposerAttachmentFileForPreview(currentFile); + onTestFinished(releasePlayback); + onTestFinished(releaseShareCopy); + + await releaseUnusedComposerAttachmentFiles([currentFile]); + expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled(); + + releasePlayback(); + releasePlayback(); + await releaseUnusedComposerAttachmentFiles([file]); + expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled(); + + const deleted = Promise.withResolvers(); + composerAttachmentCleanupMocks.remove.mockImplementationOnce(async () => { + deleted.resolve(); + return undefined; + }); + releaseShareCopy(); + await deleted.promise; + + expect(composerAttachmentCleanupMocks.remove.mock.calls).toEqual([[currentFile.fileUri]]); + }); + + it("preserves a preview opened while cleanup is checking the incoming inbox", async () => { + const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true); + onTestFinished(() => outboxLoad.mockRestore()); + const file = { + id: "file-opening-preview", + type: "file" as const, + name: "recording.mp4", + mimeType: "video/mp4", + sizeBytes: 42, + fileUri: "file:///documents/t3-composer-attachments/recording.mp4", + }; + const ownershipReadStarted = Promise.withResolvers(); + const ownershipRead = Promise.withResolvers<[]>(); + incomingShareStorageMocks.load.mockImplementationOnce(() => { + ownershipReadStarted.resolve(); + return ownershipRead.promise; + }); + + const cleanup = releaseUnusedComposerAttachmentFiles([file]); + await ownershipReadStarted.promise; + const release = retainComposerAttachmentFileForPreview(file); + onTestFinished(release); + ownershipRead.resolve([]); + await cleanup; + expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled(); + + const deleted = Promise.withResolvers(); + composerAttachmentCleanupMocks.remove.mockImplementationOnce(async () => { + deleted.resolve(); + return undefined; + }); + release(); + await deleted.promise; + expect(composerAttachmentCleanupMocks.remove.mock.calls).toEqual([[file.fileUri]]); + }); + + it("removes an unreferenced local file and its pending upload", async () => { + const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true); + onTestFinished(() => outboxLoad.mockRestore()); + const environmentId = EnvironmentId.make("environment-1"); + const file = { + id: "file-discarded", + type: "file" as const, + name: "report.pdf", + mimeType: "application/pdf", + sizeBytes: 42, + fileUri: "file:///documents/t3-composer-attachments/discarded.pdf", + uploadedAttachmentId: "pending-discarded", + uploadEnvironmentId: environmentId, + }; + + await releaseUnusedComposerAttachmentFiles([file]); + + expect(composerAttachmentCleanupMocks.remove).toHaveBeenCalledWith(file.fileUri); + expect(composerAttachmentCleanupMocks.releaseUploads).toHaveBeenCalledWith(environmentId, [ + "pending-discarded", + ]); + }); + + it("keeps a pending upload referenced through another local file", async () => { + const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true); + onTestFinished(() => outboxLoad.mockRestore()); + const environmentId = EnvironmentId.make("environment-1"); + const discarded = { + id: "file-discarded-copy", + type: "file" as const, + name: "report.pdf", + mimeType: "application/pdf", + sizeBytes: 42, + fileUri: "file:///documents/t3-composer-attachments/discarded-copy.pdf", + uploadedAttachmentId: "pending-shared", + uploadEnvironmentId: environmentId, + }; + const retained = { + ...discarded, + id: "file-retained-copy", + fileUri: "file:///documents/t3-composer-attachments/retained-copy.pdf", + }; + appAtomRegistry.set(composerDraftsAtom, { + "environment-1:thread-1": { text: "Keep this copy", attachments: [retained] }, + }); + + await releaseUnusedComposerAttachmentFiles([discarded]); + + expect(composerAttachmentCleanupMocks.remove).toHaveBeenCalledWith(discarded.fileUri); + expect(composerAttachmentCleanupMocks.releaseUploads).not.toHaveBeenCalled(); + }); + + it("completes local cleanup when pending upload deletion fails", async () => { + const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true); + onTestFinished(() => outboxLoad.mockRestore()); + const warning = vi.spyOn(console, "warn").mockImplementation(() => undefined); + onTestFinished(() => warning.mockRestore()); + composerAttachmentCleanupMocks.releaseUploads.mockRejectedValueOnce( + new Error("environment disconnected"), + ); + const file = { + id: "file-delete-failed", + type: "file" as const, + name: "report.pdf", + mimeType: "application/pdf", + sizeBytes: 42, + fileUri: "file:///documents/t3-composer-attachments/delete-failed.pdf", + uploadedAttachmentId: "pending-delete-failed", + uploadEnvironmentId: EnvironmentId.make("environment-1"), + }; + + await expect(releaseUnusedComposerAttachmentFiles([file])).resolves.toBeUndefined(); + + expect(composerAttachmentCleanupMocks.remove).toHaveBeenCalledWith(file.fileUri); + expect(warning).toHaveBeenCalledWith( + "[composer-attachments] could not remove pending upload", + expect.objectContaining({ attachmentId: "pending-delete-failed" }), + ); + }); + + it("keeps local attachment files while an outbox message still needs them", async () => { + const file = { + id: "file-queued", + type: "file" as const, + name: "report.pdf", + mimeType: "application/pdf", + sizeBytes: 42, + fileUri: "file:///documents/t3-composer-attachments/report.pdf", + }; + appAtomRegistry.set(threadOutboxManager.queuedMessagesByThreadKeyAtom, { + "environment-1:thread-1": [ + { + environmentId: EnvironmentId.make("environment-1"), + threadId: ThreadId.make("thread-1"), + messageId: MessageId.make("message-1"), + commandId: CommandId.make("command-1"), + text: "Review the report", + attachments: [file], + createdAt: "2026-08-24T12:00:00.000Z", + }, + ], + }); + + await releaseUnusedComposerAttachmentFiles([file]); + + expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled(); + }); + + it("loads persisted outbox messages before deciding an attachment file is unused", async () => { + const file = { + id: "file-persisted", + type: "file" as const, + name: "report.pdf", + mimeType: "application/pdf", + sizeBytes: 42, + fileUri: "file:///documents/t3-composer-attachments/report.pdf", + }; + const load = vi.spyOn(threadOutboxManager, "load").mockImplementation(async () => { + appAtomRegistry.set(threadOutboxManager.queuedMessagesByThreadKeyAtom, { + "environment-1:thread-1": [ + { + environmentId: EnvironmentId.make("environment-1"), + threadId: ThreadId.make("thread-1"), + messageId: MessageId.make("message-persisted"), + commandId: CommandId.make("command-persisted"), + text: "Review the report", + attachments: [file], + createdAt: "2026-08-24T12:00:00.000Z", + }, + ], + }); + return true; + }); + + try { + await releaseUnusedComposerAttachmentFiles([file]); + + expect(load).toHaveBeenCalledOnce(); + expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled(); + } finally { + load.mockRestore(); + } + }); + + it("keeps a file until its incoming share is consumed", async () => { + const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true); + onTestFinished(() => outboxLoad.mockRestore()); + const file = { + id: "file-incoming", + type: "file" as const, + name: "report.pdf", + mimeType: "application/pdf", + sizeBytes: 42, + fileUri: "file:///documents/t3-composer-attachments/incoming.pdf", + }; + incomingShareStorageMocks.load + .mockResolvedValueOnce([ + { + schemaVersion: 1, + id: "share-1", + createdAt: "2026-08-28T12:00:00.000Z", + text: "Review this file", + attachments: [file], + warnings: [], + }, + ]) + .mockResolvedValueOnce([]); + + await releaseUnusedComposerAttachmentFiles([file]); + + expect(incomingShareStorageMocks.load).toHaveBeenLastCalledWith({ strict: true }); + expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled(); + + await releaseUnusedComposerAttachmentFiles([file]); + + expect(incomingShareStorageMocks.load).toHaveBeenCalledTimes(2); + expect(composerAttachmentCleanupMocks.remove).toHaveBeenCalledWith(file.fileUri); + }); + + it("does not delete files when incoming share ownership cannot be loaded", async () => { + const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true); + onTestFinished(() => outboxLoad.mockRestore()); + const file = { + id: "file-incoming-unknown", + type: "file" as const, + name: "report.pdf", + mimeType: "application/pdf", + sizeBytes: 42, + fileUri: "file:///documents/t3-composer-attachments/incoming-unknown.pdf", + }; + const warning = vi.spyOn(console, "warn").mockImplementation(() => undefined); + incomingShareStorageMocks.load.mockRejectedValueOnce(new Error("inbox unavailable")); + onTestFinished(() => warning.mockRestore()); + + await releaseUnusedComposerAttachmentFiles([file]); + + expect(incomingShareStorageMocks.load).toHaveBeenCalledWith({ strict: true }); + expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled(); + }); + + it.each(["draft", "outbox", "inbox"] as const)( + "preserves relocated files still referenced by a persisted %s", + async (owner) => { + const fileName = "33333333-3333-4333-8333-333333333333-report.pdf"; + const oldFile = { + id: "file-relocated", + type: "file" as const, + name: "report.pdf", + mimeType: "application/pdf", + sizeBytes: 42, + fileUri: `file:///private/var/mobile/Containers/Data/Application/11111111-1111-4111-8111-111111111111/Documents/t3-composer-attachments/${fileName}`, + }; + const currentFile = { + ...oldFile, + fileUri: `file:///var/mobile/Containers/Data/Application/22222222-2222-4222-8222-222222222222/Documents/t3-composer-attachments/${fileName}`, + }; + const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true); + onTestFinished(() => outboxLoad.mockRestore()); + if (owner === "draft") { + composerDraftFileMocks.setDocument({ + schemaVersion: 1, + drafts: { "environment-1:thread-1": { text: "Saved draft", attachments: [oldFile] } }, + }); + resetComposerDraftsLoadState(); + } else if (owner === "outbox") { + outboxLoad.mockImplementation(async () => { + appAtomRegistry.set(threadOutboxManager.queuedMessagesByThreadKeyAtom, { + "environment-1:thread-1": [ + { + environmentId: EnvironmentId.make("environment-1"), + threadId: ThreadId.make("thread-1"), + messageId: MessageId.make("message-relocated"), + commandId: CommandId.make("command-relocated"), + text: "Queued draft", + attachments: [oldFile], + createdAt: "2026-08-28T12:00:00.000Z", + }, + ], + }); + return true; + }); + } else { + incomingShareStorageMocks.load.mockResolvedValue([ + { + schemaVersion: 1, + id: "share-relocated", + createdAt: "2026-08-28T12:00:00.000Z", + text: "Incoming file", + attachments: [oldFile], + warnings: [], + }, + ]); + } + + await releaseUnusedComposerAttachmentFiles([currentFile]); + + expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled(); + + appAtomRegistry.set(composerDraftsAtom, {}); + appAtomRegistry.set(threadOutboxManager.queuedMessagesByThreadKeyAtom, {}); + outboxLoad.mockResolvedValue(true); + incomingShareStorageMocks.load.mockResolvedValue([]); + await releaseUnusedComposerAttachmentFiles([currentFile]); + + expect(composerAttachmentCleanupMocks.remove).toHaveBeenCalledWith(currentFile.fileUri); + }, + ); + + it("does not delete attachment files when the draft removal cannot be saved", async () => { + const file = { + id: "file-unsaved", + type: "file" as const, + name: "report.pdf", + mimeType: "application/pdf", + sizeBytes: 42, + fileUri: "file:///documents/t3-composer-attachments/report.pdf", + }; + setComposerDraftText("environment-1:thread-1", "Unsaved draft"); + composerDraftFileMocks.setWriteError(new Error("storage unavailable")); + + try { + await expect(releaseUnusedComposerAttachmentFiles([file])).rejects.toBeInstanceOf( + ComposerDraftPersistenceError, + ); + expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled(); + } finally { + composerDraftFileMocks.setWriteError(null); + } + }); + it("hydrates selector state even when the message content is empty", () => { expect( decodePersistedComposerDrafts({ @@ -154,6 +904,195 @@ describe("mobile composer drafts", () => { ).toThrow(); }); + it("keeps share-import receipts on otherwise contentless new-task drafts", () => { + const receiptDraft: ComposerDraft = { + text: "", + attachments: [], + importedShareIds: ["share-1"], + }; + // The stale-model strip must not touch receipt-bearing drafts, and the + // empty filter must keep them — or the same share would re-import after + // restart. + expect( + decodePersistedComposerState({ + schemaVersion: 1, + drafts: { + "new-task:environment-1:project-1": { + ...receiptDraft, + modelSelection: { + instanceId: "codex", + model: "gpt-5.4", + }, + }, + }, + }).drafts, + ).toEqual({ + "new-task:environment-1:project-1": { + text: "", + attachments: [], + importedShareIds: ["share-1"], + }, + }); + + expect( + decodePersistedComposerState({ + schemaVersion: 1, + drafts: { "new-task:environment-1:project-1": receiptDraft }, + }).drafts, + ).toEqual({ "new-task:environment-1:project-1": receiptDraft }); + }); + + it("hydrates the global sticky model selection", () => { + expect( + decodePersistedComposerState({ + schemaVersion: 1, + drafts: {}, + stickyModelSelection: { + instanceId: "codex", + model: "gpt-5.6-sol", + }, + }).stickyModelSelection, + ).toEqual({ + instanceId: "codex", + model: "gpt-5.6-sol", + }); + }); + + it("waits for hydration before persisting the latest composer state", async () => { + vi.useFakeTimers(); + composerDraftFileMocks.setDocument({ + schemaVersion: 1, + drafts: { + "environment-1:thread-1": DRAFT, + }, + stickyModelSelection: { + instanceId: "codex", + model: "gpt-5.6-sol", + }, + }); + composerDraftFileMocks.blockRead(); + composerDraftFileMocks.resetWrites(); + + ensureComposerDraftsLoaded(); + await Promise.resolve(); + // The read is blocked, hydration is pending. + setComposerDraftText("new-task:environment-1:project-1", "New prompt"); + await vi.advanceTimersByTimeAsync(200); + + // Write should still be deferred — hydration has not resolved. + expect(composerDraftFileMocks.getWrites()).toHaveLength(0); + + composerDraftFileMocks.releaseRead(); + // Let the loadPromise settle and chain into the deferred persist. + await vi.runAllTimersAsync(); + + expect(JSON.parse(composerDraftFileMocks.getWrites()[0]!)).toEqual({ + schemaVersion: 1, + drafts: { + "environment-1:thread-1": DRAFT, + "new-task:environment-1:project-1": { + text: "New prompt", + attachments: [], + }, + }, + stickyModelSelection: { + instanceId: "codex", + model: "gpt-5.6-sol", + }, + }); + }); + + it("flush waits for pending hydration instead of clobbering disk", async () => { + vi.useFakeTimers(); + composerDraftFileMocks.setDocument({ + schemaVersion: 1, + drafts: { + "environment-1:thread-1": DRAFT, + }, + stickyModelSelection: { + instanceId: "codex", + model: "gpt-5.6-sol", + }, + }); + composerDraftFileMocks.blockRead(); + composerDraftFileMocks.resetWrites(); + + ensureComposerDraftsLoaded(); + await Promise.resolve(); + // An edit lands before hydration finishes; its debounced write is gated + // behind the blocked read. + setComposerDraftText("new-task:environment-1:project-1", "New prompt"); + + const flush = flushComposerDrafts(); + await vi.advanceTimersByTimeAsync(200); + // The flush must not have written the pre-hydration snapshot over disk. + expect(composerDraftFileMocks.getWrites()).toHaveLength(0); + + composerDraftFileMocks.releaseRead(); + await flush; + + const written = JSON.parse(composerDraftFileMocks.getDocument()); + expect(written.drafts["environment-1:thread-1"]).toEqual(DRAFT); + expect(written.drafts["new-task:environment-1:project-1"]).toEqual({ + text: "New prompt", + attachments: [], + }); + expect(written.stickyModelSelection).toEqual({ + instanceId: "codex", + model: "gpt-5.6-sol", + }); + }); + + it("serializes environment cleanup after an older queued write", async () => { + vi.useFakeTimers(); + composerDraftFileMocks.setDocument(JSON.stringify({ schemaVersion: 1, drafts: {} })); + composerDraftFileMocks.resetWrites(); + let releaseFirstWrite!: () => void; + const firstWriteBarrier = new Promise((resolve) => { + releaseFirstWrite = resolve; + }); + composerDraftFileMocks.setNextWriteBarrier(firstWriteBarrier); + let writeCount = 0; + const bothWritesCommitted = new Promise((resolve) => { + composerDraftFileMocks.setOnWrite(() => { + writeCount += 1; + if (writeCount === 2) { + resolve(); + } + }); + }); + + appAtomRegistry.set(composerDraftsAtom, { + "environment-1:thread-1": DRAFT, + "environment-2:thread-2": { text: "keep", attachments: [] }, + }); + setStickyComposerModelSelection({ + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.6-sol", + }); + await vi.advanceTimersByTimeAsync(200); + + const clear = clearComposerDraftsEnvironment(EnvironmentId.make("environment-1")); + await Promise.resolve(); + // Cleanup write is queued behind the still-blocked debounced write. + expect(composerDraftFileMocks.getWrites()).toHaveLength(0); + + releaseFirstWrite(); + await clear; + await bothWritesCommitted; + + expect(JSON.parse(composerDraftFileMocks.getDocument())).toEqual({ + schemaVersion: 1, + drafts: { + "environment-2:thread-2": { text: "keep", attachments: [] }, + }, + stickyModelSelection: { + instanceId: "codex", + model: "gpt-5.6-sol", + }, + }); + }); + it("clears sent content without clearing the selected model or workspace", () => { const draftKey = "environment-1:thread-1"; const draft: ComposerDraft = { @@ -182,7 +1121,7 @@ describe("mobile composer drafts", () => { }); }); - it("drops the workspace selection when clearing a sent new-task draft", () => { + it("drops draft-local model and workspace selections after sending a new task", () => { const draftKey = "new-task:environment-1:project-1"; const draft: ComposerDraft = { text: "send this", @@ -201,15 +1140,10 @@ describe("mobile composer drafts", () => { expect( clearComposerDraftContentState({ [draftKey]: draft }, draftKey, { + clearModelSelection: true, clearWorkspaceSelection: true, }), - ).toEqual({ - [draftKey]: { - modelSelection: draft.modelSelection, - text: "", - attachments: [], - }, - }); + ).toEqual({}); }); it("reads the latest selector state synchronously for send", () => { @@ -379,37 +1313,6 @@ describe("mobile composer drafts", () => { }); }); - it("waits for persisted drafts before copying content between projects", async () => { - const sourceKey = "new-task:environment-1:project-1"; - const targetKey = "new-task:environment-1:project-2"; - const unrelatedKey = "environment-1:thread-1"; - const source = { text: "Current task", attachments: [] } satisfies ComposerDraft; - const target = { text: "Persisted target", attachments: [] } satisfies ComposerDraft; - const unrelated = { text: "Keep me", attachments: [] } satisfies ComposerDraft; - - composerDraftFileMocks.setDocument({ - schemaVersion: 1, - drafts: { - [targetKey]: target, - [unrelatedKey]: unrelated, - }, - }); - composerDraftFileMocks.blockRead(); - appAtomRegistry.set(composerDraftsAtom, { [sourceKey]: source }); - - const copy = copyComposerDraftContentIfEmpty(sourceKey, targetKey); - expect(appAtomRegistry.get(composerDraftsAtom)).toEqual({ [sourceKey]: source }); - - composerDraftFileMocks.releaseRead(); - await copy; - - expect(appAtomRegistry.get(composerDraftsAtom)).toEqual({ - [sourceKey]: source, - [targetKey]: target, - [unrelatedKey]: unrelated, - }); - }); - it("lands a still-debounced draft write when flushed", async () => { const draftKey = "environment-1:thread-1"; setComposerDraftText(draftKey, "typed right before the restart"); @@ -432,4 +1335,219 @@ describe("mobile composer drafts", () => { composerDraftFileMocks.setWriteError(null); } }); + + it("restores the pre-merge snapshot when the draft is untouched since the merge", () => { + const draftKey = "environment-1:thread-1"; + const snapshot: ComposerDraft = { text: "typed before", attachments: [] }; + const merged: ComposerDraft = { + text: "typed before\n\nqueued text", + attachments: [], + runtimeMode: "approval-required", + }; + + expect(undoComposerDraftMergeState({ [draftKey]: merged }, draftKey, snapshot, merged)).toEqual( + { [draftKey]: snapshot }, + ); + expect( + undoComposerDraftMergeState( + { [draftKey]: merged }, + draftKey, + { text: "", attachments: [] }, + merged, + ), + ).toEqual({}); + }); + + it("persists an async merge rollback with the sticky model selection", async () => { + const draftKey = "environment-1:thread-1"; + const snapshot: ComposerDraft = { text: "typed before", attachments: [] }; + const merged: ComposerDraft = { + text: "typed before\n\nqueued text", + attachments: [], + }; + composerDraftFileMocks.setDocument({ + schemaVersion: 1, + drafts: { [draftKey]: merged }, + stickyModelSelection: { + instanceId: "codex", + model: "gpt-5.6-sol", + }, + }); + + await undoComposerDraftMerge(draftKey, snapshot, merged); + + expect(JSON.parse(composerDraftFileMocks.getDocument())).toEqual({ + schemaVersion: 1, + drafts: { [draftKey]: snapshot }, + stickyModelSelection: { + instanceId: "codex", + model: "gpt-5.6-sol", + }, + }); + }); + + it("returns merge-written settings to the snapshot but keeps user-edited ones", () => { + const draftKey = "environment-1:thread-1"; + const snapshot: ComposerDraft = { + text: "typed before", + attachments: [], + runtimeMode: "approval-required", + interactionMode: "default", + }; + const merged: ComposerDraft = { + text: "typed before\n\nqueued text", + attachments: [], + runtimeMode: "full-access", + interactionMode: "default", + }; + // The user edited the text (forcing the partial undo) and also switched + // interaction mode, but never touched the merge-written runtime mode. + const edited: ComposerDraft = { + text: "typed EDITED before\n\nqueued text", + attachments: [], + runtimeMode: "full-access", + interactionMode: "plan", + }; + + expect(undoComposerDraftMergeState({ [draftKey]: edited }, draftKey, snapshot, merged)).toEqual( + { + [draftKey]: { + text: "typed EDITED before", + attachments: [], + runtimeMode: "approval-required", + interactionMode: "plan", + }, + }, + ); + }); + + it("takes out only what the merge inserted when the user edited during it", () => { + const draftKey = "environment-1:thread-1"; + const keptAttachment = { + id: "kept", + type: "file" as const, + name: "kept.pdf", + mimeType: "application/pdf", + sizeBytes: 1, + fileUri: "file:///documents/t3-composer-attachments/kept.pdf", + }; + const insertedAttachment = { + id: "inserted", + type: "file" as const, + name: "inserted.pdf", + mimeType: "application/pdf", + sizeBytes: 1, + fileUri: "file:///documents/t3-composer-attachments/inserted.pdf", + }; + const userAttachment = { ...keptAttachment, id: "user-added" }; + const snapshot: ComposerDraft = { text: "typed before", attachments: [keptAttachment] }; + const merged: ComposerDraft = { + text: "typed before\n\nqueued text", + attachments: [keptAttachment, insertedAttachment], + }; + // The user rewrote the leading text and attached a file mid-recovery. + const edited: ComposerDraft = { + text: "typed EDITED before\n\nqueued text", + attachments: [keptAttachment, insertedAttachment, userAttachment], + }; + + expect(undoComposerDraftMergeState({ [draftKey]: edited }, draftKey, snapshot, merged)).toEqual( + { + [draftKey]: { + text: "typed EDITED before", + attachments: [keptAttachment, userAttachment], + }, + }, + ); + + // Edits that broke the merged suffix keep their text untouched; only the + // inserted attachments still come out. + const rewritten: ComposerDraft = { + text: "totally rewritten", + attachments: [insertedAttachment], + }; + expect( + undoComposerDraftMergeState({ [draftKey]: rewritten }, draftKey, snapshot, merged), + ).toEqual({ + [draftKey]: { text: "totally rewritten", attachments: [] }, + }); + }); + + it("keeps text appended after a merge when rolling it back", () => { + const draftKey = "environment-1:thread-1"; + const snapshot: ComposerDraft = { text: "typed before", attachments: [] }; + const content = { text: "queued text", attachments: [] }; + const merged = mergeComposerDraftContentState({ [draftKey]: snapshot }, draftKey, content)[ + draftKey + ]!; + const edited: ComposerDraft = { + ...merged, + text: `${merged.text}\n\nuser follow-up`, + }; + + const rolledBack = undoComposerDraftMergeState( + { [draftKey]: edited }, + draftKey, + snapshot, + merged, + ); + + expect(rolledBack[draftKey]?.text).toBe("typed before\n\nuser follow-up"); + const retried = mergeComposerDraftContentState(rolledBack, draftKey, content); + expect(retried[draftKey]?.text.match(/queued text/g)).toHaveLength(1); + }); + + it("spares a file re-owned between the sweep's scan and its deletion", async () => { + const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true); + onTestFinished(() => outboxLoad.mockRestore()); + const fileFor = (id: string) => ({ + id, + type: "file" as const, + name: `${id}.pdf`, + mimeType: "application/pdf", + sizeBytes: 42, + fileUri: `file:///documents/t3-composer-attachments/${id}.pdf`, + }); + const first = fileFor("file-first"); + const reowned = fileFor("file-reowned"); + // A restore re-owns the second file while the first deletion is in + // flight, after the sweep already decided both were unused. + composerAttachmentCleanupMocks.remove.mockImplementationOnce(async () => { + appAtomRegistry.set(composerDraftsAtom, { + "environment-1:thread-1": { text: "restored", attachments: [reowned] }, + }); + }); + + await releaseUnusedComposerAttachmentFiles([first, reowned]); + + expect(composerAttachmentCleanupMocks.remove.mock.calls).toEqual([[first.fileUri]]); + }); + + // Uses a fresh module instance (hydration is one-shot), so it stays last. + it("hydrates persisted drafts before a cold-start sweep deletes their files", async () => { + const file = { + id: "file-cold-start", + type: "file" as const, + name: "report.pdf", + mimeType: "application/pdf", + sizeBytes: 42, + fileUri: "file:///documents/t3-composer-attachments/report.pdf", + }; + composerDraftFileMocks.setDocument({ + schemaVersion: 1, + drafts: { + "environment-1:thread-1": { text: "Persisted draft", attachments: [file] }, + }, + }); + vi.resetModules(); + const fresh = await import("./use-composer-drafts"); + const freshRegistry = (await import("./atom-registry")).appAtomRegistry; + + await fresh.releaseUnusedComposerAttachmentFiles([file]); + + expect(freshRegistry.get(fresh.composerDraftsAtom)).toEqual({ + "environment-1:thread-1": { text: "Persisted draft", attachments: [file] }, + }); + expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled(); + }); }); diff --git a/apps/mobile/src/state/use-composer-drafts.ts b/apps/mobile/src/state/use-composer-drafts.ts index 7dbea23596c7..2a613b4914da 100644 --- a/apps/mobile/src/state/use-composer-drafts.ts +++ b/apps/mobile/src/state/use-composer-drafts.ts @@ -14,10 +14,23 @@ import { useEffect } from "react"; import { Atom } from "effect/unstable/reactivity"; import { writeFileAtomically } from "../lib/atomic-file"; -import { DraftComposerImageAttachmentSchema } from "../lib/composer-image-schema"; -import type { DraftComposerImageAttachment } from "../lib/composerImages"; +import { DraftComposerAttachmentSchema } from "../lib/composer-image-schema"; +import { + composerAttachmentFileReferenceKey, + isComposerAttachmentFileRetained, + retainComposerAttachmentFile, +} from "../lib/composerAttachmentFiles"; +import type { DraftComposerAttachment, DraftComposerFileAttachment } from "../lib/composerImages"; import { SerializedAsyncQueue } from "../lib/serialized-async-queue"; import { appAtomRegistry } from "./atom-registry"; +import { + decodeQueuedThreadMessage, + encodeQueuedThreadMessage, + QueuedThreadMessageSchema, + type QueuedThreadMessage, +} from "./thread-outbox-model"; +import { flushThreadOutbox, threadOutboxManager } from "./thread-outbox"; +import { composerDraftEnvironmentId } from "../lib/composerAttachmentUploadQueue"; const COMPOSER_DRAFTS_SCHEMA_VERSION = 1; const COMPOSER_DRAFTS_DIRECTORY = "composer-drafts"; @@ -40,7 +53,7 @@ export class ComposerDraftPersistenceError extends Schema.TaggedErrorClass; + readonly attachments: ReadonlyArray; readonly importedShareIds?: ReadonlyArray; readonly modelSelection?: ModelSelection; readonly runtimeMode?: RuntimeMode; @@ -50,7 +63,7 @@ export interface ComposerDraft { export interface ComposerDraftContent { readonly text: string; - readonly attachments: ReadonlyArray; + readonly attachments: ReadonlyArray; readonly sourceShareId?: string; } @@ -75,7 +88,7 @@ const ComposerDraftWorkspaceSelectionSchema = Schema.Struct({ const ComposerDraftSchema = Schema.Struct({ text: Schema.String, - attachments: Schema.Array(DraftComposerImageAttachmentSchema), + attachments: Schema.Array(DraftComposerAttachmentSchema), importedShareIds: Schema.optional(Schema.Array(Schema.String)), modelSelection: Schema.optional(ModelSelectionSchema), runtimeMode: Schema.optional(RuntimeModeSchema), @@ -86,6 +99,17 @@ const ComposerDraftSchema = Schema.Struct({ const PersistedComposerDraftsSchema = Schema.Struct({ schemaVersion: Schema.Literal(COMPOSER_DRAFTS_SCHEMA_VERSION), drafts: Schema.Record(Schema.String, ComposerDraftSchema), + stickyModelSelection: Schema.optional(ModelSelectionSchema), + cloudAccountId: Schema.optional(Schema.String), + signedOutDrafts: Schema.optional( + Schema.Record( + Schema.String, + Schema.Struct({ + drafts: Schema.Record(Schema.String, ComposerDraftSchema), + queuedMessages: Schema.Array(QueuedThreadMessageSchema), + }), + ), + ), }); const decodePersistedComposerDraftsDocument = Schema.decodeUnknownSync( @@ -102,10 +126,35 @@ export const composerDraftsAtom = Atom.make>({}).p Atom.withLabel("mobile:composer-drafts"), ); +export const stickyComposerModelSelectionAtom = Atom.make(null).pipe( + Atom.keepAlive, + Atom.withLabel("mobile:sticky-composer-model-selection"), +); + +interface SignedOutDrafts { + readonly drafts: Record; + readonly queuedMessages: ReadonlyArray; +} + +interface ComposerCloudDraftState { + readonly accountId: string | null; + readonly signedOut: Record; +} + +export const composerCloudDraftsAtom = Atom.make({ + accountId: null, + signedOut: {}, +}).pipe(Atom.keepAlive); + let loadPromise: Promise | null = null; let persistTimer: ReturnType | null = null; const persistenceQueue = new SerializedAsyncQueue(); +/** Resets module-level state between test runs. */ +export function resetComposerDraftsLoadState(): void { + loadPromise = null; +} + function normalizeDraft(draft: ComposerDraft | undefined): ComposerDraft { if (!draft) { return EMPTY_DRAFT; @@ -136,11 +185,59 @@ function isEmptyDraft(draft: ComposerDraft): boolean { ); } -export function decodePersistedComposerDrafts(value: unknown): Record { +export function decodePersistedComposerState(value: unknown): { + readonly drafts: Record; + readonly stickyModelSelection: ModelSelection | null; + readonly cloudDrafts: ComposerCloudDraftState; +} { const parsed = decodePersistedComposerDraftsDocument(value); - return Object.fromEntries( - Object.entries(parsed.drafts).filter(([, draft]) => !isEmptyDraft(draft)), - ); + return { + drafts: Object.fromEntries( + Object.entries(parsed.drafts) + .map( + ([key, draft]) => + [ + key, + // Stale new-task drafts left on disk by builds before the + // model-precedence fix carry a bare modelSelection with no + // other selector settings. Strip it so the next compose pass + // re-resolves project → sticky → provider defaults. Drafts + // with runtime/interaction/workspace settings or actual text / + // attachments were deliberately configured and are left alone. + key.startsWith("new-task:") && + draft.modelSelection && + draft.text.length === 0 && + draft.attachments.length === 0 && + draft.runtimeMode === undefined && + draft.interactionMode === undefined && + draft.workspaceSelection === undefined + ? { ...draft, modelSelection: undefined } + : draft, + ] as const, + ) + // importedShareIds are share-import receipts: a contentless draft + // carrying one is not empty, or the same native share would be + // re-imported after restart. + .filter(([, draft]) => !isEmptyDraft(draft) || (draft.importedShareIds?.length ?? 0) > 0), + ), + stickyModelSelection: parsed.stickyModelSelection ?? null, + cloudDrafts: { + accountId: parsed.cloudAccountId ?? null, + signedOut: Object.fromEntries( + Object.entries(parsed.signedOutDrafts ?? {}).map(([id, saved]) => [ + id, + { + drafts: saved.drafts, + queuedMessages: saved.queuedMessages.map(decodeQueuedThreadMessage), + }, + ]), + ), + }, + }; +} + +export function decodePersistedComposerDrafts(value: unknown): Record { + return decodePersistedComposerState(value).drafts; } async function getComposerDraftsFile() { @@ -150,17 +247,23 @@ async function getComposerDraftsFile() { return new File(directory, COMPOSER_DRAFTS_FILE); } -async function loadPersistedComposerDrafts(): Promise> { +async function loadPersistedComposerState(): Promise< + ReturnType +> { let operation: ComposerDraftPersistenceError["operation"] = "open"; try { const file = await getComposerDraftsFile(); if (!file.exists) { - return {}; + return { + drafts: {}, + stickyModelSelection: null, + cloudDrafts: { accountId: null, signedOut: {} }, + }; } operation = "read"; const raw = await file.text(); operation = "decode"; - return decodePersistedComposerDrafts(JSON.parse(raw) as unknown); + return decodePersistedComposerState(JSON.parse(raw) as unknown); } catch (cause) { console.warn( "[composer-drafts] ignored persisted draft failure", @@ -171,11 +274,19 @@ async function loadPersistedComposerDrafts(): Promise): Promise { +async function writePersistedComposerState( + drafts: Record, + stickyModelSelection: ModelSelection | null, + cloudDrafts = appAtomRegistry.get(composerCloudDraftsAtom), +): Promise { let operation: ComposerDraftPersistenceError["operation"] = "open"; try { const file = await getComposerDraftsFile(); @@ -186,6 +297,21 @@ async function writePersistedComposerDrafts(drafts: Record 0 + ? { + signedOutDrafts: Object.fromEntries( + Object.entries(cloudDrafts.signedOut).map(([id, saved]) => [ + id, + { + drafts: saved.drafts, + queuedMessages: saved.queuedMessages.map(encodeQueuedThreadMessage), + }, + ]), + ), + } + : {}), } as const; const encoded = JSON.stringify(document); operation = "write"; @@ -200,21 +326,18 @@ async function writePersistedComposerDrafts(drafts: Record): Promise { - try { - await persistenceQueue.run(() => writePersistedComposerDrafts(drafts)); - } catch (error) { - console.warn("[composer-drafts] failed to persist drafts", error); - // Draft persistence is best-effort; in-memory drafts still keep working. - } -} - /** * Lands any debounced or in-flight draft write before the JS runtime is torn * down (app update restart), so the freshest draft state survives it. A write * failure propagates so the caller can decide whether the restart may proceed. */ export async function flushComposerDrafts(): Promise { + // Never land a pre-hydration snapshot: persisted state must merge into the + // atoms first, or this write would clobber disk with partial data. + ensureComposerDraftsLoaded(); + if (loadPromise !== null) { + await loadPromise; + } // An edit during an awaited write schedules another debounced write, so // keep landing snapshots until no debounce is pending after a queue drain. do { @@ -222,20 +345,212 @@ export async function flushComposerDrafts(): Promise { clearTimeout(persistTimer); persistTimer = null; await persistenceQueue.run(() => - writePersistedComposerDrafts(appAtomRegistry.get(composerDraftsAtom)), + writePersistedComposerState( + appAtomRegistry.get(composerDraftsAtom), + appAtomRegistry.get(stickyComposerModelSelectionAtom), + ), ); } + // Draining also waits for an already-fired debounce whose write is still + // gated behind its own hydration await inside the queue. await persistenceQueue.run(() => Promise.resolve()); } while (persistTimer !== null); } -function schedulePersistComposerDrafts(drafts: Record): void { +function signedOutAttachmentOwners() { + return Object.values(appAtomRegistry.get(composerCloudDraftsAtom).signedOut).flatMap((saved) => [ + ...Object.values(saved.drafts), + ...saved.queuedMessages, + ]); +} + +function isComposerAttachmentFileReferenced(fileUri: string): boolean { + if (isComposerAttachmentFileRetained(fileUri)) { + return true; + } + const referenceKey = composerAttachmentFileReferenceKey(fileUri); + const drafts = Object.values(appAtomRegistry.get(composerDraftsAtom)); + const queuedMessages = Object.values( + appAtomRegistry.get(threadOutboxManager.queuedMessagesByThreadKeyAtom), + ).flat(); + return [...drafts, ...queuedMessages, ...signedOutAttachmentOwners()].some((owner) => + owner.attachments.some( + (attachment) => + attachment.type === "file" && + composerAttachmentFileReferenceKey(attachment.fileUri) === referenceKey, + ), + ); +} + +function isComposerAttachmentUploadReferenced( + environmentId: EnvironmentId, + attachmentId: string, +): boolean { + const drafts = Object.values(appAtomRegistry.get(composerDraftsAtom)); + const queuedMessages = Object.values( + appAtomRegistry.get(threadOutboxManager.queuedMessagesByThreadKeyAtom), + ).flat(); + return [...drafts, ...queuedMessages, ...signedOutAttachmentOwners()].some((owner) => + owner.attachments.some( + (attachment) => + attachment.uploadEnvironmentId === environmentId && + attachment.uploadedAttachmentId === attachmentId, + ), + ); +} + +export async function releaseUnusedComposerAttachmentFiles( + attachments: ReadonlyArray, +): Promise { + const candidates = new Set( + attachments + .filter((attachment) => attachment.type === "file") + .map((attachment) => attachment.fileUri), + ); + const uploadCandidates = new Map>(); + for (const attachment of attachments) { + if ( + attachment.uploadEnvironmentId === undefined || + attachment.uploadedAttachmentId === undefined + ) { + continue; + } + const ids = uploadCandidates.get(attachment.uploadEnvironmentId) ?? new Set(); + ids.add(attachment.uploadedAttachmentId); + uploadCandidates.set(attachment.uploadEnvironmentId, ids); + } + if (candidates.size === 0 && uploadCandidates.size === 0) { + return; + } + + // Persisted drafts must hydrate before the reference scan. On a cold start + // the atom is still empty, and every file a persisted draft owns would look + // unused. Hydrate before flushing so a pending pre-hydration write cannot + // land an incomplete snapshot either. + await waitForComposerDraftsLoaded(); + await flushComposerDrafts(); + if (!(await threadOutboxManager.load())) { + // An unreadable outbox store must not look like an empty queue: deleting + // now would take bytes a persisted queued message still needs. Skip the + // sweep; the next one retries hydration. + return; + } + await flushThreadOutbox(); + + const allFilesReferenced = [...candidates].every(isComposerAttachmentFileReferenced); + const allUploadsReferenced = [...uploadCandidates].every(([environmentId, attachmentIds]) => + [...attachmentIds].every((attachmentId) => + isComposerAttachmentUploadReferenced(environmentId, attachmentId), + ), + ); + if (allFilesReferenced && allUploadsReferenced) { + return; + } + + let incomingShareFileUris: ReadonlySet; + try { + const { loadIncomingShareDrafts } = await import("../features/sharing/incoming-share-storage"); + const incomingShares = await loadIncomingShareDrafts({ strict: true }); + incomingShareFileUris = new Set( + incomingShares.flatMap((share) => + share.attachments.flatMap((attachment) => + attachment.type === "file" + ? [composerAttachmentFileReferenceKey(attachment.fileUri)] + : [], + ), + ), + ); + } catch (error) { + console.warn("[composer-attachments] could not verify incoming share ownership", error); + return; + } + + const { removePersistedComposerAttachmentFile } = await import("../lib/composerImages"); + for (const fileUri of candidates) { + // Re-check ownership immediately before each deletion: a restore or edit + // can re-own a file after an earlier scan decided it was unused. + if ( + isComposerAttachmentFileReferenced(fileUri) || + incomingShareFileUris.has(composerAttachmentFileReferenceKey(fileUri)) + ) { + continue; + } + await removePersistedComposerAttachmentFile(fileUri); + } + + if (uploadCandidates.size > 0) { + const { releasePendingAttachmentUploads } = await import("../lib/attachmentUpload"); + for (const [environmentId, attachmentIds] of uploadCandidates) { + for (const attachmentId of attachmentIds) { + // A different draft or queued message can reuse the same pending + // upload with another local URI. Re-check the server-side ownership + // key immediately before deletion. + if (isComposerAttachmentUploadReferenced(environmentId, attachmentId)) { + continue; + } + try { + await releasePendingAttachmentUploads(environmentId, [attachmentId]); + } catch (error) { + // The server expires stale pending uploads. Local discard must still + // complete when the environment is disconnected or deletion fails. + console.warn("[composer-attachments] could not remove pending upload", { + environmentId, + attachmentId, + error, + }); + } + } + } + } +} + +export function scheduleUnusedComposerAttachmentCleanup( + attachments: ReadonlyArray, +): void { + if ( + !attachments.some( + (attachment) => attachment.type === "file" || attachment.uploadedAttachmentId !== undefined, + ) + ) { + return; + } + void releaseUnusedComposerAttachmentFiles(attachments).catch((error) => { + console.warn("[composer-attachments] could not remove unused files", error); + }); +} + +/** Keeps a native preview or upload readable until it finishes, then retries ownership cleanup. */ +export function retainComposerAttachmentFileForPreview( + attachment: DraftComposerFileAttachment, +): () => void { + return retainComposerAttachmentFile(attachment.fileUri, () => { + scheduleUnusedComposerAttachmentCleanup([attachment]); + }); +} + +function schedulePersistComposerState(): void { if (persistTimer !== null) { clearTimeout(persistTimer); } persistTimer = setTimeout(() => { persistTimer = null; - void savePersistedComposerDrafts(drafts); + ensureComposerDraftsLoaded(); + // The write enters the serialization queue before waiting on hydration, + // so flushComposerDrafts' queue drain cannot resolve ahead of it. + void persistenceQueue.run(async () => { + if (loadPromise !== null) { + await loadPromise; + } + try { + await writePersistedComposerState( + appAtomRegistry.get(composerDraftsAtom), + appAtomRegistry.get(stickyComposerModelSelectionAtom), + ); + } catch (error) { + console.warn("[composer-drafts] failed to persist drafts", error); + // Draft persistence is best-effort; in-memory drafts still keep working. + } + }); }, PERSIST_DEBOUNCE_MS); } @@ -243,16 +558,22 @@ export function ensureComposerDraftsLoaded(): void { if (loadPromise !== null) { return; } - loadPromise = loadPersistedComposerDrafts() - .then((persistedDrafts) => { - if (Object.keys(persistedDrafts).length === 0) { - return; + loadPromise = loadPersistedComposerState() + .then((persisted) => { + appAtomRegistry.set(composerCloudDraftsAtom, persisted.cloudDrafts); + if (Object.keys(persisted.drafts).length > 0) { + const current = appAtomRegistry.get(composerDraftsAtom); + appAtomRegistry.set(composerDraftsAtom, { + ...persisted.drafts, + ...current, + }); + } + if ( + persisted.stickyModelSelection !== null && + appAtomRegistry.get(stickyComposerModelSelectionAtom) === null + ) { + appAtomRegistry.set(stickyComposerModelSelectionAtom, persisted.stickyModelSelection); } - const current = appAtomRegistry.get(composerDraftsAtom); - appAtomRegistry.set(composerDraftsAtom, { - ...persistedDrafts, - ...current, - }); }) .catch((cause) => { console.warn( @@ -268,6 +589,200 @@ export function ensureComposerDraftsLoaded(): void { }); } +/** Wait until persisted drafts have been merged into the in-memory composer state. */ +export async function waitForComposerDraftsLoaded(): Promise { + ensureComposerDraftsLoaded(); + if (loadPromise !== null) { + await loadPromise; + } +} + +export async function getComposerCloudAccountId(): Promise { + await waitForComposerDraftsLoaded(); + return appAtomRegistry.get(composerCloudDraftsAtom).accountId; +} + +/** Save an account's local work before its relay environments are removed. */ +export async function archiveCloudComposerDrafts( + accountId: string | null, + environmentIds: ReadonlySet, +): Promise { + await waitForComposerDraftsLoaded(); + if (!(await threadOutboxManager.load())) throw new Error("Could not preserve queued messages."); + await flushThreadOutbox(); + const cloud = appAtomRegistry.get(composerCloudDraftsAtom); + const owner = accountId ?? cloud.accountId; + if (owner === null) return; + const queued = Object.values( + appAtomRegistry.get(threadOutboxManager.queuedMessagesByThreadKeyAtom), + ).flat(); + const current = appAtomRegistry.get(composerDraftsAtom); + const remaining = { ...current }; + const savedDrafts = { ...cloud.signedOut[owner]?.drafts }; + for (const [key, draft] of Object.entries(current)) { + const environmentId = composerDraftEnvironmentId(key, queued); + if (environmentId !== null && environmentIds.has(environmentId)) { + savedDrafts[key] = draft; + delete remaining[key]; + } + } + const savedMessages = new Map( + (cloud.signedOut[owner]?.queuedMessages ?? []).map((message) => [message.messageId, message]), + ); + for (const message of queued) { + if (environmentIds.has(message.environmentId)) savedMessages.set(message.messageId, message); + } + appAtomRegistry.set(composerDraftsAtom, remaining); + appAtomRegistry.set(composerCloudDraftsAtom, { + // Keep the owner through removal. A crash or failed cleanup can retry it + // on cold start before a different account activates. + accountId: owner, + signedOut: { + ...cloud.signedOut, + [owner]: { drafts: savedDrafts, queuedMessages: [...savedMessages.values()] }, + }, + }); + schedulePersistComposerState(); + await flushComposerDrafts(); +} + +function sameDraftAttachmentIds( + left: ReadonlyArray, + right: ReadonlyArray, +): boolean { + return ( + left.length === right.length && + left.every((attachment, index) => attachment.id === right[index]?.id) + ); +} + +/** An in-flight delivery can finish after sign-out took its snapshot. */ +export async function removeDeliveredCloudQueuedMessage( + message: QueuedThreadMessage, +): Promise { + await waitForComposerDraftsLoaded(); + const cloud = appAtomRegistry.get(composerCloudDraftsAtom); + const signedOut = { ...cloud.signedOut }; + let changed = false; + for (const [accountId, saved] of Object.entries(signedOut)) { + const archived = saved.queuedMessages.find( + (candidate) => + candidate.environmentId === message.environmentId && + candidate.messageId === message.messageId, + ); + if ( + !archived || + archived.commandId !== message.commandId || + archived.threadId !== message.threadId || + archived.text !== message.text || + !sameDraftAttachmentIds(archived.attachments, message.attachments) + ) + continue; + // Upload ids may change during preparation; user edits must remain recoverable. + if ( + JSON.stringify([ + archived.modelSelection, + archived.runtimeMode, + archived.interactionMode, + archived.creation, + ]) !== + JSON.stringify([ + message.modelSelection, + message.runtimeMode, + message.interactionMode, + message.creation, + ]) + ) + continue; + const editorKey = `pending-task:${message.messageId}`; + const editor = saved.drafts[editorKey]; + if ( + editor && + (editor.text !== message.text || + !sameDraftAttachmentIds(editor.attachments, message.attachments) || + (editor.modelSelection !== undefined && + JSON.stringify(editor.modelSelection) !== JSON.stringify(message.modelSelection)) || + (editor.runtimeMode !== undefined && editor.runtimeMode !== message.runtimeMode) || + (editor.interactionMode !== undefined && + editor.interactionMode !== message.interactionMode) || + (editor.workspaceSelection !== undefined && + (editor.workspaceSelection.mode !== message.creation?.workspaceMode || + editor.workspaceSelection.branch !== message.creation?.branch || + editor.workspaceSelection.worktreePath !== message.creation?.worktreePath || + (editor.workspaceSelection.startFromOrigin ?? false) !== + (message.creation?.startFromOrigin ?? false)))) + ) + continue; + const drafts = { ...saved.drafts }; + delete drafts[editorKey]; + signedOut[accountId] = { + drafts, + queuedMessages: saved.queuedMessages.filter((candidate) => candidate !== archived), + }; + changed = true; + } + if (!changed) return; + appAtomRegistry.set(composerCloudDraftsAtom, { ...cloud, signedOut }); + schedulePersistComposerState(); + try { + await flushComposerDrafts(); + } catch (error) { + // The live outbox can still remove this acknowledged message. Keep the + // archive update pending so a later successful flush lands it too. + schedulePersistComposerState(); + throw error; + } +} + +/** Restores only this account, before its connections can deliver queued turns. */ +export async function restoreCloudComposerDrafts(accountId: string): Promise { + await waitForComposerDraftsLoaded(); + const cloud = appAtomRegistry.get(composerCloudDraftsAtom); + const saved = cloud.signedOut[accountId]; + if (saved) { + if (!(await threadOutboxManager.load())) throw new Error("Could not restore queued messages."); + for (const message of saved.queuedMessages) { + const alreadyQueued = Object.values( + appAtomRegistry.get(threadOutboxManager.queuedMessagesByThreadKeyAtom), + ) + .flat() + .some((current) => current.messageId === message.messageId); + if (!alreadyQueued) await threadOutboxManager.enqueue(message); + } + updateComposerDrafts((current) => { + const restored = { ...current }; + for (const [key, draft] of Object.entries(saved.drafts)) { + const existing = current[key]; + const attachmentIds = new Set(existing?.attachments.map((attachment) => attachment.id)); + restored[key] = existing + ? { + ...draft, + ...existing, + text: mergeComposerDraftText(existing.text, draft.text), + // A concurrent import must not lose files, even above the send limit. + attachments: [ + ...existing.attachments, + ...draft.attachments.filter((attachment) => !attachmentIds.has(attachment.id)), + ], + importedShareIds: [ + ...new Set([ + ...(existing.importedShareIds ?? []), + ...(draft.importedShareIds ?? []), + ]), + ], + } + : draft; + } + return restored; + }); + } + const signedOut = { ...cloud.signedOut }; + delete signedOut[accountId]; + appAtomRegistry.set(composerCloudDraftsAtom, { accountId, signedOut }); + schedulePersistComposerState(); + await flushComposerDrafts(); +} + function updateComposerDrafts( update: (current: Record) => Record, ): void { @@ -277,7 +792,12 @@ function updateComposerDrafts( return; } appAtomRegistry.set(composerDraftsAtom, next); - schedulePersistComposerDrafts(next); + schedulePersistComposerState(); +} + +export function setStickyComposerModelSelection(modelSelection: ModelSelection): void { + appAtomRegistry.set(stickyComposerModelSelectionAtom, modelSelection); + schedulePersistComposerState(); } export function setComposerDraftText(draftKey: string, value: string): void { @@ -311,29 +831,49 @@ export function appendComposerDraftText(draftKey: string, value: string): void { }); } +/** + * Appends attachments to a draft, capped at the send limit against the draft's + * live state (callers may have counted before an await; the picker can race + * concurrent adds). Overflowed file attachments are released. Returns how many + * were rejected. Restore paths pass allowOverflow so a failed send never drops + * the message's own attachments. + */ export function appendComposerDraftAttachments( draftKey: string, - attachments: ReadonlyArray, -): void { + attachments: ReadonlyArray, + options?: { readonly allowOverflow?: boolean }, +): number { if (attachments.length === 0) { - return; + return 0; } + let rejected: ReadonlyArray = []; updateComposerDrafts((current) => { const existing = normalizeDraft(current[draftKey]); + const remaining = options?.allowOverflow + ? attachments.length + : Math.max(0, PROVIDER_SEND_TURN_MAX_ATTACHMENTS - existing.attachments.length); + const accepted = attachments.slice(0, remaining); + rejected = attachments.slice(remaining); + if (accepted.length === 0) { + return current; + } return { ...current, [draftKey]: { ...existing, - attachments: [...existing.attachments, ...attachments], + attachments: [...existing.attachments, ...accepted], }, }; }); + scheduleUnusedComposerAttachmentCleanup(rejected); + return rejected.length; } export function replaceComposerDraftAttachments( draftKey: string, - attachments: ReadonlyArray, + attachments: ReadonlyArray, ): void { + const previousAttachments = getComposerDraftSnapshot(draftKey).attachments; updateComposerDrafts((current) => { const draft = { ...normalizeDraft(current[draftKey]), @@ -349,9 +889,14 @@ export function replaceComposerDraftAttachments( [draftKey]: draft, }; }); + const retainedIds = new Set(attachments.map((attachment) => attachment.id)); + scheduleUnusedComposerAttachmentCleanup( + previousAttachments.filter((attachment) => !retainedIds.has(attachment.id)), + ); } export function removeComposerDraftAttachment(draftKey: string, imageId: string): void { + const previousAttachments = getComposerDraftSnapshot(draftKey).attachments; updateComposerDrafts((current) => { const existing = normalizeDraft(current[draftKey]); const draft = { @@ -368,6 +913,44 @@ export function removeComposerDraftAttachment(draftKey: string, imageId: string) [draftKey]: draft, }; }); + scheduleUnusedComposerAttachmentCleanup( + previousAttachments.filter((attachment) => attachment.id === imageId), + ); +} + +/** Stamps a finished upload without overwriting text, removals, or newer attachments. */ +export function setComposerDraftAttachmentUpload( + draftKey: string, + attachment: DraftComposerAttachment, +): boolean { + let previous: DraftComposerAttachment | undefined; + updateComposerDrafts((current) => { + const draft = current[draftKey]; + previous = draft?.attachments.find((candidate) => candidate.id === attachment.id); + if (!draft || !previous) return current; + if ( + previous.uploadedAttachmentId === attachment.uploadedAttachmentId && + previous.uploadEnvironmentId === attachment.uploadEnvironmentId + ) + return current; + return { + ...current, + [draftKey]: { + ...draft, + attachments: draft.attachments.map((candidate) => + candidate.id === attachment.id + ? { + ...candidate, + uploadedAttachmentId: attachment.uploadedAttachmentId, + uploadEnvironmentId: attachment.uploadEnvironmentId, + } + : candidate, + ), + }, + }; + }); + if (previous) scheduleUnusedComposerAttachmentCleanup([previous]); + return previous !== undefined; } export function updateComposerDraftSettings( @@ -394,15 +977,24 @@ export function updateComposerDraftSettings( export function clearComposerDraftContentState( current: Record, draftKey: string, - options?: { readonly clearWorkspaceSelection?: boolean }, + options?: { + readonly clearModelSelection?: boolean; + readonly clearWorkspaceSelection?: boolean; + }, ): Record { const existing = current[draftKey]; if (!existing) { return current; } - const { importedShareIds: _importedShareIds, workspaceSelection, ...retained } = existing; + const { + importedShareIds: _importedShareIds, + modelSelection, + workspaceSelection, + ...retained + } = existing; const draft = { ...retained, + ...(options?.clearModelSelection || modelSelection === undefined ? {} : { modelSelection }), ...(options?.clearWorkspaceSelection || workspaceSelection === undefined ? {} : { workspaceSelection }), @@ -571,7 +1163,9 @@ export async function mergeComposerDraftContent( if (next !== current) { appAtomRegistry.set(composerDraftsAtom, next); } - await persistenceQueue.run(() => writePersistedComposerDrafts(next)); + await persistenceQueue.run(() => + writePersistedComposerState(next, appAtomRegistry.get(stickyComposerModelSelectionAtom)), + ); return { skippedAttachmentCount }; } @@ -594,17 +1188,135 @@ export async function restoreComposerDraftSnapshot( snapshot, ); appAtomRegistry.set(composerDraftsAtom, next); - await persistenceQueue.run(() => writePersistedComposerDrafts(next)); + await persistenceQueue.run(() => + writePersistedComposerState(next, appAtomRegistry.get(stickyComposerModelSelectionAtom)), + ); +} + +export function sameComposerDraftState(a: ComposerDraft, b: ComposerDraft): boolean { + return ( + a.text === b.text && + a.attachments === b.attachments && + a.importedShareIds === b.importedShareIds && + a.modelSelection === b.modelSelection && + a.runtimeMode === b.runtimeMode && + a.interactionMode === b.interactionMode && + a.workspaceSelection === b.workspaceSelection + ); +} + +/** + * Undoes an abandoned mergeComposerDraftContent. When the draft is untouched + * since `merged` (the state captured right after the merge), the pre-merge + * snapshot comes back exactly. When the user edited the draft during the + * merge's awaits, only what the merge inserted (the appended text and the new + * attachments) is taken back out, so the user's edits survive the rollback. + */ +export function undoComposerDraftMergeState( + current: Record, + draftKey: string, + snapshot: ComposerDraft, + merged: ComposerDraft, +): Record { + const existing = normalizeDraft(current[draftKey]); + if (sameComposerDraftState(existing, merged)) { + return restoreComposerDraftSnapshotState(current, draftKey, snapshot); + } + const insertedText = merged.text.startsWith(snapshot.text) + ? merged.text.slice(snapshot.text.length) + : ""; + const snapshotAttachmentIds = new Set(snapshot.attachments.map((attachment) => attachment.id)); + const insertedAttachmentIds = new Set( + merged.attachments + .filter((attachment) => !snapshotAttachmentIds.has(attachment.id)) + .map((attachment) => attachment.id), + ); + // A setting still holding the merge's value is the merge's doing: restore + // the snapshot's. One the user changed since the merge stays theirs. + const undoSetting = < + K extends "modelSelection" | "runtimeMode" | "interactionMode" | "workspaceSelection", + >( + key: K, + ): ComposerDraft[K] => (existing[key] === merged[key] ? snapshot[key] : existing[key]); + const text = + insertedText.length > 0 && existing.text.startsWith(merged.text) + ? snapshot.text + existing.text.slice(merged.text.length) + : insertedText.length > 0 && existing.text.endsWith(insertedText) + ? existing.text.slice(0, existing.text.length - insertedText.length) + : existing.text; + const draft = { + ...existing, + text, + attachments: existing.attachments.filter( + (attachment) => !insertedAttachmentIds.has(attachment.id), + ), + modelSelection: undoSetting("modelSelection"), + runtimeMode: undoSetting("runtimeMode"), + interactionMode: undoSetting("interactionMode"), + workspaceSelection: undoSetting("workspaceSelection"), + }; + if (isEmptyDraft(draft)) { + const next = { ...current }; + delete next[draftKey]; + return next; + } + return { + ...current, + [draftKey]: draft, + }; +} + +/** Applies undoComposerDraftMergeState and lands it durably. */ +export async function undoComposerDraftMerge( + draftKey: string, + snapshot: ComposerDraft, + merged: ComposerDraft, +): Promise { + ensureComposerDraftsLoaded(); + if (loadPromise !== null) { + await loadPromise; + } + if (persistTimer !== null) { + clearTimeout(persistTimer); + persistTimer = null; + } + const next = undoComposerDraftMergeState( + appAtomRegistry.get(composerDraftsAtom), + draftKey, + snapshot, + merged, + ); + appAtomRegistry.set(composerDraftsAtom, next); + await persistenceQueue.run(() => + writePersistedComposerState(next, appAtomRegistry.get(stickyComposerModelSelectionAtom)), + ); } export function clearComposerDraftContent( draftKey: string, - options?: { readonly clearWorkspaceSelection?: boolean }, + options?: { + readonly clearModelSelection?: boolean; + readonly clearWorkspaceSelection?: boolean; + // Send clears the draft while the durable outbox write is still in + // flight. Sweeping then would race the write: a failed enqueue rolls the + // message out of the queue mid-sweep and its files get deleted right + // before the failure handler restores them. The sender re-schedules + // cleanup once the write settles. + readonly deferAttachmentCleanup?: boolean; + }, ): void { + const previousAttachments = getComposerDraftSnapshot(draftKey).attachments; updateComposerDrafts((current) => clearComposerDraftContentState(current, draftKey, options)); + if (!options?.deferAttachmentCleanup) { + scheduleUnusedComposerAttachmentCleanup(previousAttachments); + } } -export function clearComposerDraft(draftKey: string): void { +export function clearComposerDraft( + draftKey: string, + options?: { readonly deferAttachmentCleanup?: boolean }, +): void { + const previousAttachments = getComposerDraftSnapshot(draftKey).attachments; updateComposerDrafts((current) => { if (!current[draftKey]) { return current; @@ -613,6 +1325,9 @@ export function clearComposerDraft(draftKey: string): void { delete next[draftKey]; return next; }); + if (!options?.deferAttachmentCleanup) { + scheduleUnusedComposerAttachmentCleanup(previousAttachments); + } } export function removeComposerDraftsForEnvironment( @@ -635,17 +1350,21 @@ export async function clearComposerDraftsEnvironment(environmentId: EnvironmentI await loadPromise; } - const next = removeComposerDraftsForEnvironment( - appAtomRegistry.get(composerDraftsAtom), - environmentId, - ); + const current = appAtomRegistry.get(composerDraftsAtom); + const next = removeComposerDraftsForEnvironment(current, environmentId); + const removedAttachments = Object.entries(current) + .filter(([draftKey]) => next[draftKey] === undefined) + .flatMap(([, draft]) => draft.attachments); if (persistTimer !== null) { clearTimeout(persistTimer); persistTimer = null; } appAtomRegistry.set(composerDraftsAtom, next); - await persistenceQueue.run(() => writePersistedComposerDrafts(next)); + await persistenceQueue.run(() => + writePersistedComposerState(next, appAtomRegistry.get(stickyComposerModelSelectionAtom)), + ); + await releaseUnusedComposerAttachmentFiles(removedAttachments); } export function useComposerDraft(draftKey: string | null): ComposerDraft { @@ -655,3 +1374,11 @@ export function useComposerDraft(draftKey: string | null): ComposerDraft { }, []); return draftKey ? normalizeDraft(drafts[draftKey]) : EMPTY_DRAFT; } + +export function useStickyComposerModelSelection(): ModelSelection | null { + const selection = useAtomValue(stickyComposerModelSelectionAtom); + useEffect(() => { + ensureComposerDraftsLoaded(); + }, []); + return selection; +} diff --git a/apps/mobile/src/state/use-composer-path-search.ts b/apps/mobile/src/state/use-composer-path-search.ts deleted file mode 100644 index 485b472dcb05..000000000000 --- a/apps/mobile/src/state/use-composer-path-search.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { type ComposerPathSearchTarget } from "@t3tools/client-runtime/state/threads"; - -import { useComposerPathSearch as useComposerPathSearchQuery } from "../state/queries"; - -export function useComposerPathSearch(target: ComposerPathSearchTarget) { - return useComposerPathSearchQuery(target); -} diff --git a/apps/mobile/src/state/use-remote-environment-registry.ts b/apps/mobile/src/state/use-remote-environment-registry.ts index 6fb41fc091f1..4f5f455522bc 100644 --- a/apps/mobile/src/state/use-remote-environment-registry.ts +++ b/apps/mobile/src/state/use-remote-environment-registry.ts @@ -1,26 +1,20 @@ import { useAtomValue } from "@effect/atom-react"; -import type { PreparedConnection } from "@t3tools/client-runtime/connection"; import type { EnvironmentId } from "@t3tools/contracts"; -import type { ServerConfig } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; -import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { useCallback, useMemo } from "react"; import { Alert } from "react-native"; -import { useEnvironmentServerConfig } from "../state/entities"; import { useConnectionController } from "../features/connection/useConnectionController"; -import { environmentPresentations, useEnvironmentPresentation } from "./presentation"; -import { - projectEnvironmentPresentation, - type EnvironmentPresentation, -} from "../state/environments"; +import { environmentPresentations } from "./presentation"; import { useWorkspaceState } from "../state/workspace"; import type { SavedRemoteConnection } from "../lib/connection"; import { appAtomRegistry } from "./atom-registry"; import type { ConnectedEnvironmentSummary, EnvironmentRuntimeState } from "./remote-runtime-types"; -import { environmentSession, usePreparedConnection } from "./session"; +import { environmentSession } from "./session"; import { environmentCatalog } from "../connection/catalog"; +import { createRemoteEnvironmentProjectionAtoms } from "./remote-environment-projections"; +import { serverEnvironment } from "./server"; const connectionPairingUrlAtom = Atom.make("").pipe( Atom.keepAlive, @@ -36,65 +30,30 @@ export function setPendingConnectionError(message: string | null): void { appAtomRegistry.set(pendingConnectionErrorAtom, message); } -function toSavedConnection( - environment: EnvironmentPresentation, - prepared: Option.Option, -): SavedRemoteConnection { - const displayUrl = environment.displayUrl ?? ""; - const active = Option.getOrNull(prepared); - const httpBaseUrl = active?.httpBaseUrl ?? displayUrl; - const socketUrl = active?.socketUrl ?? ""; - const wsBaseUrl = - socketUrl === "" - ? displayUrl.startsWith("https://") - ? displayUrl.replace(/^https:/, "wss:") - : displayUrl.replace(/^http:/, "ws:") - : new URL(socketUrl).origin; - const authorization = active?.httpAuthorization ?? null; +const remoteEnvironmentProjections = createRemoteEnvironmentProjectionAtoms({ + presentationAtom: environmentPresentations.presentationAtom, + preparedConnectionAtom: environmentSession.preparedConnectionValueAtom, + serverConfigAtom: serverEnvironment.configValueAtom, +}); - return { - environmentId: environment.environmentId, - environmentLabel: environment.label, - pairingUrl: displayUrl, - displayUrl, - httpBaseUrl, - wsBaseUrl, - bearerToken: authorization?._tag === "Bearer" ? authorization.token : null, - ...(environment.relayManaged - ? { - authenticationMethod: "dpop" as const, - relayManaged: true as const, - ...(authorization?._tag === "Dpop" ? { dpopAccessToken: authorization.accessToken } : {}), - } - : { authenticationMethod: "bearer" as const }), - }; -} +const EMPTY_SAVED_CONNECTION_ATOM = Atom.make(null).pipe( + Atom.withLabel("mobile:saved-connection:empty"), +); + +const EMPTY_RUNTIME_STATE_ATOM = Atom.make(null).pipe( + Atom.withLabel("mobile:environment-runtime-state:empty"), +); const savedConnectionsByIdAtom = Atom.make((get) => { const presentationById = get(environmentPresentations.presentationsAtom); return Object.fromEntries( - [...presentationById.entries()].map(([environmentId, presentation]) => [ - environmentId, - toSavedConnection( - projectEnvironmentPresentation(environmentId, presentation), - get(environmentSession.preparedConnectionValueAtom(environmentId)), - ), - ]), + [...presentationById.keys()].flatMap((environmentId) => { + const connection = get(remoteEnvironmentProjections.savedConnectionAtom(environmentId)); + return connection === null ? [] : [[environmentId, connection]]; + }), ) as Record; }).pipe(Atom.withLabel("mobile:saved-connections-by-id")); -function toRuntimeState( - environment: EnvironmentPresentation, - serverConfig: ServerConfig | null, -): EnvironmentRuntimeState { - return { - connectionState: environment.connection.phase, - connectionError: environment.connection.error, - connectionErrorTraceId: environment.connection.traceId, - serverConfig, - }; -} - export function useSavedRemoteConnections() { const catalog = useAtomValue(environmentCatalog.catalogValueAtom); const savedConnectionsById = useAtomValue(savedConnectionsByIdAtom); @@ -108,23 +67,21 @@ export function useSavedRemoteConnections() { export function useSavedRemoteConnection( environmentId: EnvironmentId | null, ): SavedRemoteConnection | null { - const { presentation } = useEnvironmentPresentation(environmentId); - const prepared = usePreparedConnection(environmentId); - if (environmentId === null || presentation === null) { - return null; - } - return toSavedConnection(projectEnvironmentPresentation(environmentId, presentation), prepared); + return useAtomValue( + environmentId === null + ? EMPTY_SAVED_CONNECTION_ATOM + : remoteEnvironmentProjections.savedConnectionAtom(environmentId), + ); } export function useRemoteEnvironmentRuntime( environmentId: EnvironmentId | null, ): EnvironmentRuntimeState | null { - const { presentation } = useEnvironmentPresentation(environmentId); - const serverConfig = useEnvironmentServerConfig(environmentId); - if (environmentId === null || presentation === null) { - return null; - } - return toRuntimeState(projectEnvironmentPresentation(environmentId, presentation), serverConfig); + return useAtomValue( + environmentId === null + ? EMPTY_RUNTIME_STATE_ATOM + : remoteEnvironmentProjections.runtimeStateAtom(environmentId), + ); } export function useRemoteConnectionStatus() { diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index dd7ace60ad99..66e57802d1a6 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -6,6 +6,7 @@ import * as Cause from "effect/Cause"; import { CommandId, MessageId, + PROVIDER_SEND_TURN_MAX_ATTACHMENTS, type EnvironmentId, type ModelSelection, type ProviderInteractionMode, @@ -26,7 +27,8 @@ import { makeQueuedMessageMetadata } from "../lib/commandMetadata"; import { convertPastedImagesToAttachments, pasteComposerClipboard, - pickComposerImages, + pickComposerFiles, + pickComposerMedia, } from "../lib/composerImages"; import type { DraftComposerImageAttachment } from "../lib/composerImages"; import { scopedThreadKey } from "../lib/scopedEntities"; @@ -42,6 +44,7 @@ import { getComposerDraftSnapshot, mergeComposerDraftContent, removeComposerDraftAttachment, + scheduleUnusedComposerAttachmentCleanup, setComposerDraftText, updateComposerDraftSettings, useComposerDraft, @@ -53,6 +56,10 @@ import { enqueueThreadOutboxMessage } from "./thread-outbox"; import { useThreadOutboxMessages } from "./use-thread-outbox"; import { threadEnvironment } from "./threads"; import { useAtomCommand } from "./use-atom-command"; +import { + composerAttachmentUploadBlockReason, + composerAttachmentUploadsAtom, +} from "./composer-attachment-uploads"; export function appendReviewCommentToDraft(input: { readonly environmentId: EnvironmentId; @@ -65,7 +72,14 @@ export function appendReviewCommentToDraft(input: { const separator = existing.trim().length > 0 && !existing.endsWith("\n") ? "\n\n" : ""; setComposerDraftText(threadKey, `${existing}${separator}${input.text}`); if (input.attachments && input.attachments.length > 0) { - appendComposerDraftAttachments(threadKey, input.attachments); + // Capped: a review comment is new content, not a send-failure restore, so + // it must not push the draft over the send limit. Overflow is released. + const rejectedCount = appendComposerDraftAttachments(threadKey, input.attachments); + if (rejectedCount > 0) { + setPendingConnectionError( + `${rejectedCount} comment attachment${rejectedCount === 1 ? " was" : "s were"} not added. Messages can contain at most ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} attachments.`, + ); + } } } @@ -168,9 +182,30 @@ export function useThreadComposerState() { const thread = selectedThreadDetail ?? selectedThreadShell; const text = draft.text.trim(); const attachments = draft.attachments; + if ( + composerAttachmentUploadBlockReason({ + environmentId: selectedThreadShell.environmentId, + attachments, + connected: selectedEnvironmentRuntime?.connectionState === "connected", + serverConfig: selectedEnvironmentRuntime?.serverConfig ?? null, + states: appAtomRegistry.get(composerAttachmentUploadsAtom), + }) !== null + ) + return null; if (text.length === 0 && attachments.length === 0) { return null; } + // A send-failure restore appends with allowOverflow so it never drops the + // user's files, which can leave the draft over the cap. Sending it anyway + // would enqueue a message that outbox recovery rejects forever, so block + // here until the user removes attachments. + if (attachments.length > PROVIDER_SEND_TURN_MAX_ATTACHMENTS) { + Alert.alert( + "Too many attachments", + `Remove attachments until there are at most ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS}.`, + ); + return null; + } const provider = selectedEnvironmentRuntime?.serverConfig?.providers.find( (entry) => entry.instanceId === thread.modelSelection.instanceId, @@ -255,21 +290,30 @@ export function useThreadComposerState() { interactionMode: draft.interactionMode ?? thread.interactionMode, createdAt: metadata.createdAt, }); - clearComposerDraftContent(threadKey); - enqueuePromise.catch((error: unknown) => { - // Restore text via merge (idempotent) but attachments via the uncapped - // append: the merge path slots existing attachments first and truncates - // at the send limit, which would silently drop this message's images if - // the user attached new ones while the write was in flight. - void mergeComposerDraftContent(threadKey, { text, attachments: [] }); - appendComposerDraftAttachments(threadKey, attachments); - setPendingConnectionError( - error instanceof Error ? error.message : "Failed to save the queued message.", - ); - }); + clearComposerDraftContent(threadKey, { deferAttachmentCleanup: true }); + enqueuePromise.then( + () => { + // The queued message owns the files now; the sweep sees that and + // spares them. Deferred to here so a failed write cannot roll the + // message out of the queue mid-sweep and lose the bytes. + scheduleUnusedComposerAttachmentCleanup(attachments); + }, + (error: unknown) => { + // Restore text via merge (idempotent) but attachments via the uncapped + // append: the merge path slots existing attachments first and truncates + // at the send limit, which would silently drop this message's images if + // the user attached new ones while the write was in flight. + void mergeComposerDraftContent(threadKey, { text, attachments: [] }); + appendComposerDraftAttachments(threadKey, attachments, { allowOverflow: true }); + setPendingConnectionError( + error instanceof Error ? error.message : "Failed to save the queued message.", + ); + }, + ); return messageId; }, [ - selectedEnvironmentRuntime?.serverConfig?.providers, + selectedEnvironmentRuntime?.connectionState, + selectedEnvironmentRuntime?.serverConfig, selectedThreadDetail, selectedThreadShell, uploadThreadFeedback, @@ -287,22 +331,63 @@ export function useThreadComposerState() { [selectedThreadShell], ); - const onPickDraftImages = useCallback(async () => { + const onPickDraftMedia = useCallback(async () => { if (!selectedThreadShell) { return; } const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id); - const result = await pickComposerImages({ + const capabilities = selectedEnvironmentRuntime?.serverConfig?.environment.capabilities; + const result = await pickComposerMedia({ existingCount: composerDrafts[threadKey]?.attachments.length ?? 0, + maxVideoBytes: + capabilities?.attachmentUploads === true + ? capabilities.fileAttachments?.maxUploadBytes + : undefined, }); - if (result.images.length > 0) { - appendComposerDraftAttachments(threadKey, result.images); + const rejectedCount = appendComposerDraftAttachments(threadKey, result.attachments); + const problems = [ + ...(result.error ? [result.error] : []), + ...(rejectedCount > 0 + ? [`You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} attachments per message.`] + : []), + ]; + if (problems.length > 0) { + Alert.alert("Could not attach photo or video", problems.join("\n\n")); } - if (result.error) { - setPendingConnectionError(result.error); + }, [composerDrafts, selectedEnvironmentRuntime?.serverConfig, selectedThreadShell]); + + const onPickDraftFiles = useCallback(async () => { + if (!selectedThreadShell) { + return; } - }, [composerDrafts, selectedThreadShell]); + const maxBytes = + selectedEnvironmentRuntime?.serverConfig?.environment.capabilities.fileAttachments + ?.maxUploadBytes; + if (maxBytes === undefined) { + Alert.alert("Could not attach file", "This server does not support file attachments."); + return; + } + + const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id); + // pickComposerFiles clamps the advertised limit to the contract maximum. + const result = await pickComposerFiles({ + existingCount: composerDrafts[threadKey]?.attachments.length ?? 0, + maxBytes, + }); + const rejectedCount = appendComposerDraftAttachments(threadKey, result.files); + // The picker error and the live-cap rejection can both happen in one + // pick; report both in a single alert. + const problems = [ + ...(result.error ? [result.error] : []), + ...(rejectedCount > 0 + ? [`You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} files per message.`] + : []), + ]; + if (problems.length > 0) { + Alert.alert("Could not attach file", problems.join("\n\n")); + } + }, [composerDrafts, selectedEnvironmentRuntime?.serverConfig, selectedThreadShell]); const onPasteIntoDraft = useCallback(async () => { if (!selectedThreadShell) { @@ -313,14 +398,16 @@ export function useThreadComposerState() { const result = await pasteComposerClipboard({ existingCount: composerDrafts[threadKey]?.attachments.length ?? 0, }); - if (result.images.length > 0) { - appendComposerDraftAttachments(threadKey, result.images); - } + const rejectedPasteCount = appendComposerDraftAttachments(threadKey, result.images); if (result.text) { appendComposerDraftText(threadKey, result.text); } if (result.error) { setPendingConnectionError(result.error); + } else if (rejectedPasteCount > 0) { + setPendingConnectionError( + `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} files per message.`, + ); } }, [composerDrafts, selectedThreadShell]); @@ -403,7 +490,8 @@ export function useThreadComposerState() { runtimeMode, interactionMode, onChangeDraftMessage, - onPickDraftImages, + onPickDraftMedia, + onPickDraftFiles, onPasteIntoDraft, onNativePasteImages, onRemoveDraftImage, diff --git a/apps/mobile/src/state/use-thread-outbox-drain.test.ts b/apps/mobile/src/state/use-thread-outbox-drain.test.ts new file mode 100644 index 000000000000..d27f07962d60 --- /dev/null +++ b/apps/mobile/src/state/use-thread-outbox-drain.test.ts @@ -0,0 +1,641 @@ +import { + CommandId, + EnvironmentId, + MessageId, + ProjectId, + ProviderInstanceId, + ThreadId, +} from "@t3tools/contracts"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import type { PreparedTurnAttachments } from "../lib/attachmentUpload"; + +const harness = vi.hoisted(() => ({ + manager: null as unknown as ReturnType< + typeof import("./thread-outbox-manager").createThreadOutboxManager + >, + removePersistedFile: vi.fn(async () => undefined), + removeOutboxMessage: vi.fn(async (_message: QueuedThreadMessage) => undefined), + prepareTurnAttachments: vi.fn(), + setPendingConnectionError: vi.fn(), + draftFile: (() => { + let document = ""; + let writeError: Error | null = null; + return { + setDocument(value: unknown) { + document = JSON.stringify(value); + }, + setWriteError(error: Error | null) { + writeError = error; + }, + Directory: class { + create() {} + }, + File: class { + exists = true; + parentDirectory = null; + + create() {} + + moveSync() {} + + async text() { + return document; + } + + write(value: string) { + if (writeError) { + throw writeError; + } + document = value; + } + }, + }; + })(), +})); + +vi.mock("expo-file-system", () => ({ + Directory: harness.draftFile.Directory, + File: harness.draftFile.File, + Paths: { document: "/documents" }, +})); + +vi.mock("../lib/composerImages", () => ({ + removePersistedComposerAttachmentFile: harness.removePersistedFile, + toUploadChatImageAttachments: () => [], +})); + +vi.mock("../lib/uuid", () => ({ + uuidv4: () => "00000000-0000-4000-8000-000000000000", + randomHex: () => "abcd", +})); + +vi.mock("../lib/attachmentUpload", () => ({ + prepareTurnAttachments: harness.prepareTurnAttachments, +})); + +vi.mock("./entities", () => ({ + useProjects: () => [], + useServerConfigs: () => new Map(), + useThreadShells: () => [], +})); + +vi.mock("./threads", () => ({ + threadEnvironment: {}, +})); + +vi.mock("./use-atom-command", () => ({ + useAtomCommand: () => async () => undefined, +})); + +vi.mock("./use-thread-outbox", async () => { + const { Atom } = await import("effect/unstable/reactivity"); + return { + editingQueuedMessageIdsAtom: Atom.make>({}).pipe(Atom.keepAlive), + useThreadOutboxMessages: () => ({}), + useThreadOutboxShellStatuses: () => new Map(), + }; +}); + +vi.mock("./use-remote-environment-registry", () => ({ + setPendingConnectionError: harness.setPendingConnectionError, + useRemoteConnectionStatus: () => ({ connectedEnvironments: [] }), +})); + +vi.mock("./thread-outbox", async () => { + const { createThreadOutboxManager } = await import("./thread-outbox-manager"); + const { appAtomRegistry } = await import("./atom-registry"); + harness.manager = createThreadOutboxManager({ + registry: appAtomRegistry, + storage: { + load: async () => [], + write: async () => undefined, + remove: (message) => harness.removeOutboxMessage(message), + }, + }); + const manager = harness.manager; + return { + threadOutboxManager: manager, + flushThreadOutbox: async () => undefined, + ensureThreadOutboxLoaded: () => undefined, + confirmThreadOutboxMessageQueued: (message: never) => manager.confirmQueued(message), + updateThreadOutboxMessage: (message: never, expectedRevision?: number) => + manager.update(message, expectedRevision), + threadOutboxRevision: (messageId: never) => manager.revisionOf(messageId), + }; +}); + +import { appAtomRegistry } from "./atom-registry"; +import type { QueuedThreadMessage } from "./thread-outbox-model"; +import * as composerDrafts from "./use-composer-drafts"; +import { editingQueuedMessageIdsAtom } from "./use-thread-outbox"; +import { + completeQueuedMessageDelivery, + prepareQueuedMessageAttachments, + recoverEditedCreationAfterDelivery, + removeAcknowledgedExistingThreadMessage, + restoreRejectedQueuedMessage, +} from "./use-thread-outbox-drain"; + +function queuedMessage(input: { + readonly messageId: string; + readonly text: string; + readonly fileUri?: string; +}): QueuedThreadMessage { + return { + environmentId: EnvironmentId.make("environment-1"), + threadId: ThreadId.make("thread-1"), + messageId: MessageId.make(input.messageId), + commandId: CommandId.make(`command-${input.messageId}`), + text: input.text, + attachments: input.fileUri + ? [ + { + id: `file-${input.messageId}`, + type: "file", + name: "report.pdf", + mimeType: "application/pdf", + sizeBytes: 42, + fileUri: input.fileUri, + }, + ] + : [], + createdAt: "2026-08-24T12:00:00.000Z", + }; +} + +function withReusedFileUpload( + message: QueuedThreadMessage, + attachmentId: string, +): QueuedThreadMessage { + return { + ...message, + attachments: message.attachments.map((attachment) => + attachment.type === "file" + ? { + ...attachment, + uploadedAttachmentId: attachmentId, + uploadEnvironmentId: message.environmentId, + } + : attachment, + ), + }; +} + +function remainingMessages(): ReadonlyArray { + return Object.values(appAtomRegistry.get(harness.manager.queuedMessagesByThreadKeyAtom)).flat(); +} + +beforeEach(() => { + harness.draftFile.setDocument({ schemaVersion: 1, drafts: {} }); +}); + +afterEach(() => { + appAtomRegistry.set(harness.manager.queuedMessagesByThreadKeyAtom, {}); + appAtomRegistry.set(composerDrafts.composerDraftsAtom, {}); + appAtomRegistry.set(composerDrafts.composerCloudDraftsAtom, { accountId: null, signedOut: {} }); + appAtomRegistry.set(editingQueuedMessageIdsAtom, {}); + harness.draftFile.setWriteError(null); + harness.removePersistedFile.mockClear(); + harness.removeOutboxMessage.mockClear(); + harness.prepareTurnAttachments.mockReset(); + harness.setPendingConnectionError.mockClear(); +}); + +describe("thread outbox attachment preparation", () => { + it("abandons reused uploads when an editor saves changed text during verification", async () => { + const message = withReusedFileUpload( + queuedMessage({ + messageId: "message-reused-upload-race", + text: "original text", + fileUri: "file:///documents/t3-composer-attachments/reused.pdf", + }), + "pending-reused-upload", + ); + const preparationStarted = Promise.withResolvers(); + const preparationBarrier = Promise.withResolvers(); + const releaseUploads = vi.fn(async () => undefined); + harness.prepareTurnAttachments.mockImplementationOnce(async () => { + preparationStarted.resolve(); + return preparationBarrier.promise; + }); + await harness.manager.enqueue(message); + appAtomRegistry.set(editingQueuedMessageIdsAtom, { [message.messageId]: true }); + + const preparation = prepareQueuedMessageAttachments(message); + await preparationStarted.promise; + const edited = { ...message, text: "saved editor text" }; + await harness.manager.update(edited); + appAtomRegistry.set(editingQueuedMessageIdsAtom, {}); + preparationBarrier.resolve({ + status: "ready", + attachments: [], + draftAttachments: message.attachments, + pendingAttachmentIds: ["pending-reused-upload"], + releaseUploads, + }); + + await expect(preparation).resolves.toEqual({ status: "abandoned" }); + expect(remainingMessages()).toEqual([edited]); + expect(releaseUploads).not.toHaveBeenCalled(); + }); + + it("keeps an unchanged queued payload ready after attachment reuse", async () => { + const message = withReusedFileUpload( + queuedMessage({ + messageId: "message-reused-upload-current", + text: "unchanged text", + fileUri: "file:///documents/t3-composer-attachments/current.pdf", + }), + "pending-reused-upload", + ); + const releaseUploads = vi.fn(async () => undefined); + harness.prepareTurnAttachments.mockResolvedValueOnce({ + status: "ready", + attachments: [], + draftAttachments: message.attachments, + pendingAttachmentIds: ["pending-reused-upload"], + releaseUploads, + }); + await harness.manager.enqueue(message); + const revision = harness.manager.revisionOf(message.messageId); + appAtomRegistry.set(editingQueuedMessageIdsAtom, { [message.messageId]: true }); + + await expect(prepareQueuedMessageAttachments(message)).resolves.toMatchObject({ + status: "ready", + persistedMessage: message, + deliveryRevision: revision, + }); + expect(releaseUploads).not.toHaveBeenCalled(); + }); + + it("uses the known next revision after persisting uploaded references", async () => { + const message = queuedMessage({ + messageId: "message-new-upload-revision", + text: "upload this file", + fileUri: "file:///documents/t3-composer-attachments/new.pdf", + }); + const uploadedAttachments = message.attachments.map((attachment) => + attachment.type === "file" + ? { + ...attachment, + uploadedAttachmentId: "pending-new-upload", + uploadEnvironmentId: message.environmentId, + } + : attachment, + ); + harness.prepareTurnAttachments.mockImplementationOnce(async (input) => { + expect(await input.persistUploadedReferences?.(uploadedAttachments)).toBe("persisted"); + return { + status: "ready", + attachments: [], + draftAttachments: uploadedAttachments, + pendingAttachmentIds: ["pending-new-upload"], + releaseUploads: async () => undefined, + }; + }); + await harness.manager.enqueue(message); + const revision = harness.manager.revisionOf(message.messageId); + + const result = await prepareQueuedMessageAttachments(message); + + expect(result).toMatchObject({ + status: "ready", + persistedMessage: { attachments: uploadedAttachments }, + deliveryRevision: revision + 1, + }); + expect(harness.manager.revisionOf(message.messageId)).toBe(revision + 1); + }); + + it("does not prepare a payload that was already replaced", async () => { + const message = queuedMessage({ messageId: "message-stale-before-upload", text: "old" }); + await harness.manager.enqueue(message); + const edited = { ...message, text: "new" }; + await harness.manager.update(edited); + + await expect(prepareQueuedMessageAttachments(message)).resolves.toEqual({ + status: "abandoned", + }); + expect(harness.prepareTurnAttachments).not.toHaveBeenCalled(); + expect(remainingMessages()).toEqual([edited]); + }); +}); + +describe("thread outbox drain delivery cleanup", () => { + it("removes an acknowledged outbox item even when the sign-out archive write fails", async () => { + const message = queuedMessage({ messageId: "archive-write-failure", text: "Delivered" }); + await harness.manager.enqueue(message); + await composerDrafts.archiveCloudComposerDrafts("account-a", new Set([message.environmentId])); + harness.draftFile.setWriteError(new Error("Draft storage unavailable")); + + await expect( + completeQueuedMessageDelivery(message, harness.manager.revisionOf(message.messageId)), + ).resolves.toBe("removed"); + expect(remainingMessages()).toEqual([]); + + harness.draftFile.setWriteError(null); + await composerDrafts.flushComposerDrafts(); + appAtomRegistry.set(composerDrafts.composerCloudDraftsAtom, { accountId: null, signedOut: {} }); + composerDrafts.resetComposerDraftsLoadState(); + await composerDrafts.restoreCloudComposerDrafts("account-a"); + expect(remainingMessages()).toEqual([]); + }); + + it.each([false, true])( + "does not restore a message delivered after the sign-out snapshot (outbox already cleared: %s)", + async (cleared) => { + const message = queuedMessage({ + messageId: "delivered-during-sign-out", + text: "Already delivered", + }); + await harness.manager.enqueue(message); + const deliveryRevision = harness.manager.revisionOf(message.messageId); + await composerDrafts.archiveCloudComposerDrafts( + "account-a", + new Set([message.environmentId]), + ); + expect( + appAtomRegistry.get(composerDrafts.composerCloudDraftsAtom).signedOut["account-a"] + ?.queuedMessages, + ).toEqual([message]); + + if (cleared) await harness.manager.clearEnvironment(message.environmentId); + await expect(completeQueuedMessageDelivery(message, deliveryRevision)).resolves.toBe( + cleared ? "edited" : "removed", + ); + + // Restart before signing back in: the archived copy must be removed on disk too. + appAtomRegistry.set(composerDrafts.composerCloudDraftsAtom, { + accountId: null, + signedOut: {}, + }); + composerDrafts.resetComposerDraftsLoadState(); + await composerDrafts.restoreCloudComposerDrafts("account-a"); + expect(remainingMessages()).toEqual([]); + }, + ); + + it("preserves an archived edit when an older payload finishes delivery", async () => { + const message = queuedMessage({ messageId: "edited-during-sign-out", text: "Original" }); + await harness.manager.enqueue(message); + const deliveryRevision = harness.manager.revisionOf(message.messageId); + const edited = { ...message, text: "Keep this edit" }; + await harness.manager.update(edited); + await composerDrafts.archiveCloudComposerDrafts("account-a", new Set([message.environmentId])); + await harness.manager.clearEnvironment(message.environmentId); + await expect(completeQueuedMessageDelivery(message, deliveryRevision)).resolves.toBe("edited"); + await composerDrafts.restoreCloudComposerDrafts("account-a"); + expect(remainingMessages()).toEqual([edited]); + }); + + it("retries only cleanup after an acknowledged send removal fails", async () => { + const message = queuedMessage({ messageId: "message-acknowledged", text: "delivered" }); + const acknowledged = new Set([message.messageId]); + harness.removeOutboxMessage.mockRejectedValueOnce(new Error("storage unavailable")); + await harness.manager.enqueue(message); + + await expect(removeAcknowledgedExistingThreadMessage(message, acknowledged)).resolves.toBe( + false, + ); + expect(remainingMessages()).toEqual([message]); + expect(acknowledged).toEqual(new Set([message.messageId])); + + await expect(removeAcknowledgedExistingThreadMessage(message, acknowledged)).resolves.toBe( + true, + ); + expect(remainingMessages()).toEqual([]); + expect(acknowledged).toEqual(new Set()); + }); + + it("keeps an edited message and its files when delivery cleanup loses the revision race", async () => { + const message = queuedMessage({ + messageId: "message-edited", + text: "original", + fileUri: "file:///documents/t3-composer-attachments/report.pdf", + }); + await harness.manager.enqueue(message); + const deliveryRevision = harness.manager.revisionOf(message.messageId); + const edited = { ...message, text: "edited while the turn delivered" }; + await harness.manager.update(edited); + + await expect(completeQueuedMessageDelivery(message, deliveryRevision)).resolves.toBe("edited"); + + expect(remainingMessages()).toEqual([edited]); + expect(harness.removePersistedFile).not.toHaveBeenCalled(); + }); + + it("removes the delivered message when no edit was accepted", async () => { + const message = queuedMessage({ messageId: "message-clean", text: "hello" }); + await harness.manager.enqueue(message); + const deliveryRevision = harness.manager.revisionOf(message.messageId); + + await expect(completeQueuedMessageDelivery(message, deliveryRevision)).resolves.toBe("removed"); + + expect(remainingMessages()).toEqual([]); + }); + + it("keeps a delivered message when its editor opens during storage removal", async () => { + const message = queuedMessage({ + messageId: "message-editor-removal-race", + text: "keep editor changes", + fileUri: "file:///documents/t3-composer-attachments/editor-race.pdf", + }); + const removeStarted = Promise.withResolvers(); + const removeBarrier = Promise.withResolvers(); + harness.removeOutboxMessage.mockImplementationOnce(async () => { + removeStarted.resolve(); + await removeBarrier.promise; + }); + await harness.manager.enqueue(message); + const deliveryRevision = harness.manager.revisionOf(message.messageId); + + const cleanup = completeQueuedMessageDelivery(message, deliveryRevision); + await removeStarted.promise; + appAtomRegistry.set(editingQueuedMessageIdsAtom, { [message.messageId]: true }); + removeBarrier.resolve(); + + await expect(cleanup).resolves.toBe("edited"); + expect(remainingMessages()).toEqual([message]); + expect(harness.removePersistedFile).not.toHaveBeenCalled(); + }); +}); + +describe("thread outbox delivered creation recovery", () => { + it("keeps an edit accepted while the older payload is persisted to the draft", async () => { + const message = queuedMessage({ + messageId: "message-recovery-race", + text: "original queued text", + fileUri: "file:///documents/t3-composer-attachments/report.pdf", + }); + const originalMergeComposerDraftContent = composerDrafts.mergeComposerDraftContent; + const mergeCompleted = Promise.withResolvers(); + const releaseRecovery = Promise.withResolvers(); + const mergeSpy = vi + .spyOn(composerDrafts, "mergeComposerDraftContent") + .mockImplementation(async (draftKey, content) => { + const result = await originalMergeComposerDraftContent(draftKey, content); + mergeCompleted.resolve(); + await releaseRecovery.promise; + return result; + }); + + try { + await harness.manager.enqueue(message); + const recovery = recoverEditedCreationAfterDelivery(message); + await mergeCompleted.promise; + + const newer = { ...message, text: "edited while recovery persisted the draft" }; + await harness.manager.update(newer); + + releaseRecovery.resolve(); + await expect(recovery).resolves.toBe(false); + + expect(remainingMessages()).toEqual([newer]); + expect( + composerDrafts.getComposerDraftSnapshot(`${message.environmentId}:${message.threadId}`), + ).toMatchObject({ text: message.text, attachments: [] }); + expect(harness.removePersistedFile).not.toHaveBeenCalled(); + } finally { + releaseRecovery.resolve(); + mergeSpy.mockRestore(); + } + }); + + it("leaves recovery to an editor that opens while the draft persists", async () => { + const message = queuedMessage({ + messageId: "message-recovery-editor", + text: "recover this text", + fileUri: "file:///documents/t3-composer-attachments/editor.pdf", + }); + const originalMergeComposerDraftContent = composerDrafts.mergeComposerDraftContent; + const mergeCompleted = Promise.withResolvers(); + const releaseRecovery = Promise.withResolvers(); + const mergeSpy = vi + .spyOn(composerDrafts, "mergeComposerDraftContent") + .mockImplementation(async (draftKey, content) => { + const result = await originalMergeComposerDraftContent(draftKey, content); + mergeCompleted.resolve(); + await releaseRecovery.promise; + return result; + }); + + try { + await harness.manager.enqueue(message); + const recovery = recoverEditedCreationAfterDelivery(message); + await mergeCompleted.promise; + appAtomRegistry.set(editingQueuedMessageIdsAtom, { [message.messageId]: true }); + + releaseRecovery.resolve(); + await expect(recovery).resolves.toBe(true); + + expect(remainingMessages()).toEqual([message]); + expect( + composerDrafts.getComposerDraftSnapshot(`${message.environmentId}:${message.threadId}`), + ).toMatchObject({ text: message.text, attachments: [] }); + expect(harness.removePersistedFile).not.toHaveBeenCalled(); + } finally { + releaseRecovery.resolve(); + mergeSpy.mockRestore(); + } + }); + + it("retries a failed removal without duplicating recovered draft content", async () => { + const message = queuedMessage({ + messageId: "message-recovery-removal", + text: "recover once", + fileUri: "file:///documents/t3-composer-attachments/retry.pdf", + }); + const draftKey = `${message.environmentId}:${message.threadId}`; + const removeSpy = vi + .spyOn(harness.manager, "remove") + .mockRejectedValueOnce(new Error("storage unavailable")); + + try { + await harness.manager.enqueue(message); + + await expect(recoverEditedCreationAfterDelivery(message)).resolves.toBe(false); + expect(remainingMessages()).toEqual([message]); + + await expect(recoverEditedCreationAfterDelivery(message)).resolves.toBe(true); + + const draft = composerDrafts.getComposerDraftSnapshot(draftKey); + expect(draft.text).toBe(message.text); + expect(draft.attachments).toEqual(message.attachments); + expect(remainingMessages()).toEqual([]); + expect(harness.removePersistedFile).not.toHaveBeenCalled(); + } finally { + removeSpy.mockRestore(); + } + }); + + it("keeps the queue entry when the recovered draft cannot persist", async () => { + const message = queuedMessage({ + messageId: "message-recovery-persistence", + text: "recover after persistence returns", + }); + await harness.manager.enqueue(message); + harness.draftFile.setWriteError(new Error("disk full")); + + await expect(recoverEditedCreationAfterDelivery(message)).resolves.toBe(false); + + expect(remainingMessages()).toEqual([message]); + }); +}); + +describe("thread outbox recovery rollback", () => { + it("restores a rejected new task into its durable project draft", async () => { + const message: QueuedThreadMessage = { + ...queuedMessage({ messageId: "message-creation-restore", text: "new task text" }), + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.6-sol" }, + creation: { + projectId: ProjectId.make("project-1"), + workspaceMode: "local", + branch: null, + worktreePath: null, + }, + }; + await harness.manager.enqueue(message); + + await expect(restoreRejectedQueuedMessage(message, "rejected by server")).resolves.toBe( + "restored", + ); + + expect( + composerDrafts.getComposerDraftSnapshot( + `new-task:${message.environmentId}:${message.creation!.projectId}`, + ), + ).toMatchObject({ + text: message.text, + attachments: message.attachments, + modelSelection: message.modelSelection, + }); + expect(remainingMessages()).toEqual([]); + expect(harness.setPendingConnectionError).toHaveBeenCalledWith("rejected by server"); + }); + + it("rolls a failed recovery merge back so the retry cannot duplicate the text", async () => { + const message = queuedMessage({ messageId: "message-restore", text: "queued text" }); + const draftKey = `${message.environmentId}:${message.threadId}`; + appAtomRegistry.set(composerDrafts.composerDraftsAtom, { + [draftKey]: { text: "typed offline", attachments: [] }, + }); + await harness.manager.enqueue(message); + + harness.draftFile.setWriteError(new Error("disk full")); + await expect(restoreRejectedQueuedMessage(message, "too large")).resolves.toBe("retry"); + + // The merge was rolled back and the message stayed queued for the retry. + expect(composerDrafts.getComposerDraftSnapshot(draftKey).text).toBe("typed offline"); + expect(remainingMessages()).toEqual([message]); + + harness.draftFile.setWriteError(null); + await expect(restoreRejectedQueuedMessage(message, "too large")).resolves.toBe("restored"); + + // The recovered text landed exactly once and the message left the queue. + expect(composerDrafts.getComposerDraftSnapshot(draftKey).text).toBe( + "typed offline\n\nqueued text", + ); + expect(remainingMessages()).toEqual([]); + expect(harness.setPendingConnectionError).toHaveBeenCalledWith("too large"); + }); +}); diff --git a/apps/mobile/src/state/use-thread-outbox-drain.ts b/apps/mobile/src/state/use-thread-outbox-drain.ts index 68c973ff97e3..de6a538b52ef 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.ts @@ -8,6 +8,7 @@ import { CommandId, DEFAULT_PROVIDER_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, + PROVIDER_SEND_TURN_MAX_ATTACHMENTS, type MessageId, } from "@t3tools/contracts"; import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git"; @@ -15,36 +16,57 @@ import * as Cause from "effect/Cause"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { useCallback, useEffect, useRef, useState } from "react"; -import { scopedThreadKey } from "../lib/scopedEntities"; +import { scopedProjectKey, scopedThreadKey } from "../lib/scopedEntities"; import { buildProjectThreadStartTurnInput } from "../lib/projectThreadStartTurn"; -import { toUploadChatImageAttachments } from "../lib/composerImages"; +import { prepareTurnAttachments, type PreparedTurnAttachments } from "../lib/attachmentUpload"; import { randomHex } from "../lib/uuid"; import { appAtomRegistry } from "./atom-registry"; -import { useProjects, useThreadShells } from "./entities"; +import { useProjects, useServerConfigs, useThreadShells } from "./entities"; import { confirmThreadOutboxMessageQueued, ensureThreadOutboxLoaded, - removeThreadOutboxMessage, + threadOutboxManager, + threadOutboxRevision, + updateThreadOutboxMessage, } from "./thread-outbox"; +import { removeThreadOutboxMessage } from "./thread-outbox-removal"; import { isQueuedThreadCreationSendable, modelSelectionsEqual, resolveThreadOutboxDeliveryAction, + resolveThreadOutboxDispatchStep, resolveThreadOutboxFailureAction, resolveQueuedThreadSettings, + shouldRetryThreadOutboxDelivery, threadOutboxRetryDelayMs, type QueuedThreadCreation, type QueuedThreadMessage, type ThreadOutboxCommandStage, } from "./thread-outbox-model"; -import { threadEnvironment } from "./threads"; +import { environmentThreadShells, threadEnvironment } from "./threads"; +import { + appendComposerDraftAttachments, + composerDraftsAtom, + flushComposerDrafts, + type ComposerDraft, + getComposerDraftSnapshot, + mergeComposerDraftContent, + replaceComposerDraftAttachments, + removeDeliveredCloudQueuedMessage, + undoComposerDraftMerge, + updateComposerDraftSettings, + waitForComposerDraftsLoaded, +} from "./use-composer-drafts"; import { useAtomCommand } from "./use-atom-command"; import { editingQueuedMessageIdsAtom, useThreadOutboxMessages, useThreadOutboxShellStatuses, } from "./use-thread-outbox"; -import { useRemoteConnectionStatus } from "./use-remote-environment-registry"; +import { + setPendingConnectionError, + useRemoteConnectionStatus, +} from "./use-remote-environment-registry"; export const dispatchingQueuedMessageIdAtom = Atom.make(null).pipe( Atom.keepAlive, @@ -85,6 +107,395 @@ function settingsCommandId(message: QueuedThreadMessage, setting: string): Comma return CommandId.make(`${message.commandId}:${setting}`); } +/** + * Uploads a queued message's attachments and persists the uploaded ids back + * onto the queued message. The revision-checked update means an edit accepted + * while the bytes uploaded wins: this attempt abandons and the next drain pass + * re-reads the message. + * `deliveryRevision` is the revision of the payload this attempt will send, + * used for the delivery removal's compare-and-set. + */ +export async function prepareQueuedMessageAttachments( + queuedMessage: QueuedThreadMessage, + supportsImageUploads = false, +): Promise< + | { + readonly status: "ready"; + readonly prepared: PreparedTurnAttachments; + readonly persistedMessage: QueuedThreadMessage; + readonly deliveryRevision: number; + } + | { readonly status: "abandoned" } +> { + if (!(await confirmThreadOutboxMessageQueued(queuedMessage))) { + return { status: "abandoned" }; + } + const revision = threadOutboxRevision(queuedMessage.messageId); + if (!isQueuedMessagePayloadCurrent(queuedMessage, revision)) { + return { status: "abandoned" }; + } + let persistedMessage = queuedMessage; + let deliveryRevision = revision; + const result = await prepareTurnAttachments({ + environmentId: queuedMessage.environmentId, + attachments: queuedMessage.attachments, + supportsImageUploads, + persistUploadedReferences: async (draftAttachments) => { + if (appAtomRegistry.get(editingQueuedMessageIdsAtom)[queuedMessage.messageId]) { + return "abandon"; + } + const updatedMessage = { ...queuedMessage, attachments: draftAttachments }; + if (!(await updateThreadOutboxMessage(updatedMessage, revision))) { + return "abandon"; + } + persistedMessage = updatedMessage; + deliveryRevision = revision + 1; + return "persisted"; + }, + }); + if ( + result.status === "abandoned" || + !isQueuedMessagePayloadCurrent(persistedMessage, deliveryRevision) + ) { + return { status: "abandoned" }; + } + return { status: "ready", prepared: result, persistedMessage, deliveryRevision }; +} + +function isQueuedMessagePayloadCurrent( + message: QueuedThreadMessage, + expectedRevision: number, +): boolean { + return ( + threadOutboxRevision(message.messageId) === expectedRevision && + Object.values(appAtomRegistry.get(threadOutboxManager.queuedMessagesByThreadKeyAtom)) + .flat() + .some((candidate) => candidate === message) + ); +} + +/** + * Removes a delivered message from the outbox. The revision and editor checks + * preserve a creation payload when its pending-task editor owns newer work. + * The outcome tells the caller whether removal completed, ownership changed, + * or storage cleanup failed. Exported for tests. + */ +export async function completeQueuedMessageDelivery( + queuedMessage: QueuedThreadMessage, + deliveryRevision: number, +): Promise<"removed" | "edited" | "failed"> { + try { + await removeDeliveredCloudQueuedMessage(queuedMessage).catch((error) => { + console.warn("[thread-outbox] could not update sign-out snapshot after delivery", { + messageId: queuedMessage.messageId, + error, + }); + }); + // The editor may have taken the entry while startTurn was in flight; its + // unsaved edits have not bumped the revision yet, so the CAS alone would + // let removal win and the editor would lose them once it saves. + if (appAtomRegistry.get(editingQueuedMessageIdsAtom)[queuedMessage.messageId]) { + return "edited"; + } + // Removal also releases the message's local attachment files. + const removed = await removeThreadOutboxMessage( + queuedMessage, + deliveryRevision, + () => !appAtomRegistry.get(editingQueuedMessageIdsAtom)[queuedMessage.messageId], + ); + if (!removed) { + console.warn( + "[thread-outbox] delivered message was edited before cleanup; keeping the newer message", + { + environmentId: queuedMessage.environmentId, + threadId: queuedMessage.threadId, + messageId: queuedMessage.messageId, + }, + ); + return "edited"; + } + return "removed"; + } catch (error) { + console.warn("[thread-outbox] failed to remove delivered queued message", { + environmentId: queuedMessage.environmentId, + threadId: queuedMessage.threadId, + messageId: queuedMessage.messageId, + error, + }); + return "failed"; + } +} + +/** Retries local cleanup for an existing-thread send acknowledged in this drain lifetime. */ +export async function removeAcknowledgedExistingThreadMessage( + queuedMessage: QueuedThreadMessage, + acknowledgedMessageIds: Set, +): Promise { + try { + await removeDeliveredCloudQueuedMessage(queuedMessage).catch((error) => { + console.warn("[thread-outbox] could not update sign-out snapshot after delivery", { + messageId: queuedMessage.messageId, + error, + }); + }); + const removed = await removeThreadOutboxMessage(queuedMessage); + if (removed) { + acknowledgedMessageIds.delete(queuedMessage.messageId); + } + return removed; + } catch (error) { + console.warn("[thread-outbox] failed to remove acknowledged queued message", { + environmentId: queuedMessage.environmentId, + threadId: queuedMessage.threadId, + messageId: queuedMessage.messageId, + error, + }); + return false; + } +} + +/** + * A creation delivered its startTurn but an edit won the cleanup race, so the + * edited payload is still queued. The next drain would see the created thread + * and take the creation "remove" path, silently discarding the edit; hand the + * edited content to the new thread's composer instead and remove the entry. + * Returns true when recovery is complete or an open editor owns the next + * action, and false when the drain should retry with backoff. + * Exported for tests; the drain is the only production caller. + */ +export async function recoverEditedCreationAfterDelivery( + queuedMessage: QueuedThreadMessage, +): Promise { + const kept = Object.values(appAtomRegistry.get(threadOutboxManager.queuedMessagesByThreadKeyAtom)) + .flat() + .find((candidate) => candidate.messageId === queuedMessage.messageId); + if (!kept) { + return true; + } + const keptRevision = threadOutboxRevision(kept.messageId); + if (appAtomRegistry.get(editingQueuedMessageIdsAtom)[kept.messageId]) { + return true; + } + const draftKey = scopedThreadKey(kept.environmentId, kept.threadId); + try { + // Merge before removing: the draft's reference keeps the removal sweep + // from deleting the attachment files. allowOverflow mirrors the + // send-failure restore; the send path refuses over-cap drafts, so the + // state stays recoverable. + await mergeComposerDraftContent(draftKey, { text: kept.text, attachments: [] }); + if (appAtomRegistry.get(editingQueuedMessageIdsAtom)[kept.messageId]) { + return true; + } + if (threadOutboxRevision(kept.messageId) !== keptRevision) { + return false; + } + const existingAttachmentIds = new Set( + getComposerDraftSnapshot(draftKey).attachments.map((attachment) => attachment.id), + ); + appendComposerDraftAttachments( + draftKey, + kept.attachments.filter((attachment) => !existingAttachmentIds.has(attachment.id)), + { allowOverflow: true }, + ); + // Only settings the queued message actually carries: spreading explicit + // undefined would clear choices the user already made on the draft. + updateComposerDraftSettings(draftKey, { + ...(kept.modelSelection !== undefined ? { modelSelection: kept.modelSelection } : {}), + ...(kept.runtimeMode !== undefined ? { runtimeMode: kept.runtimeMode } : {}), + ...(kept.interactionMode !== undefined ? { interactionMode: kept.interactionMode } : {}), + }); + // The append only schedules a debounced write; the queue entry is the + // only durable copy until the draft lands, so flush before removing. + await flushComposerDrafts(); + } catch (error) { + // Keep the entry queued. The drain retries with backoff, and the merge is + // idempotent so content that persisted before the failure is not repeated. + console.warn("[thread-outbox] could not hand an edited pending task to the composer", error); + return false; + } + if (appAtomRegistry.get(editingQueuedMessageIdsAtom)[kept.messageId]) { + return true; + } + try { + return await removeThreadOutboxMessage( + kept, + keptRevision, + () => !appAtomRegistry.get(editingQueuedMessageIdsAtom)[kept.messageId], + ); + } catch (error) { + console.warn("[thread-outbox] could not remove recovered pending task", error); + return false; + } +} + +/** Exported for tests; the drain is the only production caller. */ +export async function restoreRejectedQueuedMessage( + queuedMessage: QueuedThreadMessage, + message: string, +): Promise<"restored" | "deferred" | "blocked" | "retry"> { + const draftKey = recoveryDraftKey(queuedMessage); + // Set once the merge publishes, cleared once the queued message is removed. + // The catch below uses it to take the merged content back out, so a retry + // after a mid-recovery failure cannot append the recovered text again. + let rollback: { readonly snapshot: ComposerDraft; readonly merged: ComposerDraft } | null = null; + try { + if ( + appAtomRegistry.get(editingQueuedMessageIdsAtom)[queuedMessage.messageId] || + !(await confirmThreadOutboxMessageQueued(queuedMessage)) || + appAtomRegistry.get(editingQueuedMessageIdsAtom)[queuedMessage.messageId] + ) { + return "deferred"; + } + // The confirmation above checked this exact payload is what is queued, so + // the current revision guards the removal at the end against an edit + // accepted while this recovery ran. + const revision = threadOutboxRevision(queuedMessage.messageId); + + await waitForComposerDraftsLoaded(); + if (appAtomRegistry.get(editingQueuedMessageIdsAtom)[queuedMessage.messageId]) { + return "deferred"; + } + const originalDraft = getComposerDraftSnapshot(draftKey); + const existingAttachmentIds = new Set( + originalDraft.attachments.map((attachment) => attachment.id), + ); + const addedAttachmentCount = queuedMessage.attachments.filter( + (attachment) => !existingAttachmentIds.has(attachment.id), + ).length; + if (existingAttachmentIds.size + addedAttachmentCount > PROVIDER_SEND_TURN_MAX_ATTACHMENTS) { + setPendingConnectionError( + `Remove attachments from the draft before restoring this message. Messages can contain at most ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} attachments.`, + ); + return "blocked"; + } + + let mergedDraft: ComposerDraft; + try { + await mergeComposerDraftContent(draftKey, { + text: queuedMessage.text, + attachments: queuedMessage.attachments, + }); + } finally { + // Snapshots for the rollbacks below: undoComposerDraftMerge restores + // the original draft only while it is untouched, and otherwise takes + // out just what this recovery inserted so edits typed during the awaits + // survive. Captured in a finally because mergeComposerDraftContent + // publishes before its persistence await: even its failure leaves the + // merged content in the draft. + mergedDraft = getComposerDraftSnapshot(draftKey); + rollback = { snapshot: originalDraft, merged: mergedDraft }; + } + if (appAtomRegistry.get(editingQueuedMessageIdsAtom)[queuedMessage.messageId]) { + await undoComposerDraftMerge(draftKey, originalDraft, mergedDraft); + return "deferred"; + } + updateComposerDraftSettings(draftKey, { + ...(queuedMessage.modelSelection ? { modelSelection: queuedMessage.modelSelection } : {}), + ...(queuedMessage.runtimeMode ? { runtimeMode: queuedMessage.runtimeMode } : {}), + ...(queuedMessage.interactionMode ? { interactionMode: queuedMessage.interactionMode } : {}), + ...(queuedMessage.creation + ? { + workspaceSelection: { + mode: queuedMessage.creation.workspaceMode, + branch: queuedMessage.creation.branch, + worktreePath: queuedMessage.creation.worktreePath, + ...(queuedMessage.creation.startFromOrigin !== undefined + ? { startFromOrigin: queuedMessage.creation.startFromOrigin } + : {}), + }, + } + : {}), + }); + const restoredDraft = getComposerDraftSnapshot(draftKey); + rollback = { snapshot: originalDraft, merged: restoredDraft }; + await flushComposerDrafts(); + if ( + appAtomRegistry.get(editingQueuedMessageIdsAtom)[queuedMessage.messageId] || + !(await confirmThreadOutboxMessageQueued(queuedMessage)) || + appAtomRegistry.get(editingQueuedMessageIdsAtom)[queuedMessage.messageId] + ) { + await undoComposerDraftMerge(draftKey, originalDraft, restoredDraft); + return "deferred"; + } + // Revision-checked: an edit that landed after the confirmation above + // must not be deleted with the pre-edit payload this recovery restored. + if ( + !(await removeThreadOutboxMessage( + queuedMessage, + revision, + () => !appAtomRegistry.get(editingQueuedMessageIdsAtom)[queuedMessage.messageId], + )) + ) { + await undoComposerDraftMerge(draftKey, originalDraft, restoredDraft); + return "deferred"; + } + // The queued message is gone; from here the draft owns the content and + // must never be rolled back. + rollback = null; + setPendingConnectionError(message); + return "restored"; + } catch (error) { + if (rollback !== null) { + // Take the recovered content back out (keeping edits typed since) so + // the retry's merge starts clean instead of appending a duplicate. The + // in-memory rollback lands even when its own persistence write fails. + await undoComposerDraftMerge(draftKey, rollback.snapshot, rollback.merged).catch( + (undoError) => { + console.warn("[thread-outbox] failed to persist a recovery rollback", undoError); + }, + ); + } + console.warn("[thread-outbox] failed to restore an undeliverable message", error); + setPendingConnectionError( + error instanceof Error ? error.message : "The unsent message could not be restored.", + ); + return "retry"; + } +} + +function recoveryDraftKey(queuedMessage: QueuedThreadMessage): string { + return queuedMessage.creation + ? `new-task:${scopedProjectKey(queuedMessage.environmentId, queuedMessage.creation.projectId)}` + : scopedThreadKey(queuedMessage.environmentId, queuedMessage.threadId); +} + +async function preserveUploadedAttachmentsForEditor( + originalMessage: QueuedThreadMessage, + uploadedMessage: QueuedThreadMessage, +): Promise { + if (!originalMessage.creation) { + return; + } + + const draftKey = `pending-task:${originalMessage.messageId}`; + const draft = getComposerDraftSnapshot(draftKey); + const uploadedById = new Map( + uploadedMessage.attachments.map((attachment) => [attachment.id, attachment] as const), + ); + let changed = false; + const nextAttachments = draft.attachments.map((attachment) => { + const uploaded = uploadedById.get(attachment.id); + if ( + !uploaded?.uploadedAttachmentId || + uploaded.uploadEnvironmentId !== originalMessage.environmentId || + (attachment.uploadedAttachmentId === uploaded.uploadedAttachmentId && + attachment.uploadEnvironmentId === uploaded.uploadEnvironmentId) + ) { + return attachment; + } + changed = true; + return { + ...attachment, + uploadedAttachmentId: uploaded.uploadedAttachmentId, + uploadEnvironmentId: uploaded.uploadEnvironmentId, + }; + }); + if (changed) { + replaceComposerDraftAttachments(draftKey, nextAttachments); + await flushComposerDrafts(); + } +} + export function useThreadOutboxDrain(): void { const startTurn = useAtomCommand(threadEnvironment.startTurn, { reportFailure: false }); const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { @@ -102,11 +513,76 @@ export function useThreadOutboxDrain(): void { const shellStatuses = useThreadOutboxShellStatuses(); const threads = useThreadShells(); const projects = useProjects(); + const serverConfigs = useServerConfigs(); const { connectedEnvironments } = useRemoteConnectionStatus(); const [retryTick, setRetryTick] = useState(0); const retryAttemptRef = useRef(new Map()); const retryNotBeforeRef = useRef(new Map()); const retryTimersRef = useRef(new Map>()); + const acknowledgedExistingThreadMessageIdsRef = useRef(new Set()); + const blockedRecoverySubscriptionsRef = useRef( + new Map< + MessageId, + { readonly message: QueuedThreadMessage; readonly unsubscribe: () => void } + >(), + ); + + const scheduleQueuedMessageRetry = useCallback((messageId: MessageId) => { + const retryAttempt = (retryAttemptRef.current.get(messageId) ?? 0) + 1; + retryAttemptRef.current.set(messageId, retryAttempt); + const retryDelayMs = threadOutboxRetryDelayMs(retryAttempt); + retryNotBeforeRef.current.set(messageId, Date.now() + retryDelayMs); + const pendingTimer = retryTimersRef.current.get(messageId); + if (pendingTimer !== undefined) { + clearTimeout(pendingTimer); + } + const retryTimer = setTimeout(() => { + retryTimersRef.current.delete(messageId); + setRetryTick((current) => current + 1); + }, retryDelayMs); + retryTimersRef.current.set(messageId, retryTimer); + }, []); + + const restoreQueuedMessage = useCallback( + async (queuedMessage: QueuedThreadMessage, message: string): Promise => { + const result = await restoreRejectedQueuedMessage(queuedMessage, message); + if (result !== "blocked") { + return result !== "retry"; + } + + if (!blockedRecoverySubscriptionsRef.current.has(queuedMessage.messageId)) { + const draftKey = recoveryDraftKey(queuedMessage); + const editorDraftKey = queuedMessage.creation + ? `pending-task:${queuedMessage.messageId}` + : null; + const currentDrafts = appAtomRegistry.get(composerDraftsAtom); + const blockedAttachments = currentDrafts[draftKey]?.attachments; + const editorAttachments = + editorDraftKey === null ? undefined : currentDrafts[editorDraftKey]?.attachments; + const unsubscribe = appAtomRegistry.subscribe(composerDraftsAtom, (drafts) => { + if ( + drafts[draftKey]?.attachments === blockedAttachments && + (editorDraftKey === null || drafts[editorDraftKey]?.attachments === editorAttachments) + ) { + return; + } + const active = blockedRecoverySubscriptionsRef.current.get(queuedMessage.messageId); + if (!active) { + return; + } + blockedRecoverySubscriptionsRef.current.delete(queuedMessage.messageId); + active.unsubscribe(); + setRetryTick((current) => current + 1); + }); + blockedRecoverySubscriptionsRef.current.set(queuedMessage.messageId, { + message: queuedMessage, + unsubscribe, + }); + } + return true; + }, + [], + ); useEffect(() => { ensureThreadOutboxLoaded(); @@ -115,6 +591,10 @@ export function useThreadOutboxDrain(): void { clearTimeout(timer); } retryTimersRef.current.clear(); + for (const blocked of blockedRecoverySubscriptionsRef.current.values()) { + blocked.unsubscribe(); + } + blockedRecoverySubscriptionsRef.current.clear(); }; }, []); @@ -122,53 +602,36 @@ export function useThreadOutboxDrain(): void { const reportFailure = ( commandResult: AtomCommandResult, stage: ThreadOutboxCommandStage, - ): boolean => { + ): { readonly action: "retry" | "restore"; readonly message: string } | null => { if (!AsyncResult.isFailure(commandResult)) { - return false; + return null; } + const error = Cause.squash(commandResult.cause); const action = resolveThreadOutboxFailureAction({ stage, - error: Cause.squash(commandResult.cause), + error, interrupted: Cause.hasInterruptsOnly(commandResult.cause), }); - const retry = action === "retry"; console.warn("[thread-outbox] queued message delivery failed", { environmentId: queuedMessage.environmentId, threadId: queuedMessage.threadId, messageId: queuedMessage.messageId, stage, cause: commandResult.cause, - retry, + action, }); - return retry; + return { + action, + message: error instanceof Error ? error.message : "The message could not be sent.", + }; }; - const completeDelivery = async ( - deliveryResult: AtomCommandResult, - ): Promise => { - if (reportFailure(deliveryResult, "start-turn")) { - return false; - } - - try { - await removeThreadOutboxMessage(queuedMessage); - return true; - } catch (error) { - console.warn("[thread-outbox] failed to remove delivered queued message", { - environmentId: queuedMessage.environmentId, - threadId: queuedMessage.threadId, - messageId: queuedMessage.messageId, - error, - }); - return false; - } - }; - return { reportFailure, completeDelivery }; + return { reportFailure }; }, []); const sendQueuedMessage = useCallback( async (queuedMessage: QueuedThreadMessage, thread: EnvironmentThreadShell) => { const settings = resolveQueuedThreadSettings(queuedMessage, thread); - const { reportFailure, completeDelivery } = makeDeliveryHelpers(queuedMessage); + const { reportFailure } = makeDeliveryHelpers(queuedMessage); if (!modelSelectionsEqual(settings.modelSelection, thread.modelSelection)) { const updateResult = await updateThreadMetadata({ @@ -217,6 +680,41 @@ export function useThreadOutboxDrain(): void { } } + let prepared: PreparedTurnAttachments; + let persistedMessage: QueuedThreadMessage; + let deliveryRevision: number; + try { + const preparedResult = await prepareQueuedMessageAttachments( + queuedMessage, + serverConfigs.get(queuedMessage.environmentId)?.environment.capabilities + .attachmentUploads === true, + ); + if (preparedResult.status === "abandoned") { + return true; + } + prepared = preparedResult.prepared; + persistedMessage = preparedResult.persistedMessage; + deliveryRevision = preparedResult.deliveryRevision; + if (appAtomRegistry.get(editingQueuedMessageIdsAtom)[queuedMessage.messageId]) { + await preserveUploadedAttachmentsForEditor( + queuedMessage, + preparedResult.persistedMessage, + ); + return true; + } + } catch (error) { + console.warn("[thread-outbox] failed to upload attachments", error); + if (!shouldRetryThreadOutboxDelivery(error)) { + return restoreQueuedMessage( + queuedMessage, + error instanceof Error ? error.message : "An attachment could not upload.", + ); + } + return false; + } + if (!isQueuedMessagePayloadCurrent(persistedMessage, deliveryRevision)) { + return true; + } const deliveryResult = await startTurn({ environmentId: queuedMessage.environmentId, input: { @@ -226,7 +724,7 @@ export function useThreadOutboxDrain(): void { messageId: queuedMessage.messageId, role: "user", text: queuedMessage.text, - attachments: toUploadChatImageAttachments(queuedMessage.attachments), + attachments: prepared.attachments, }, modelSelection: settings.modelSelection, runtimeMode: settings.runtimeMode, @@ -234,7 +732,26 @@ export function useThreadOutboxDrain(): void { createdAt: queuedMessage.createdAt, }, }); - return completeDelivery(deliveryResult); + const failure = reportFailure(deliveryResult, "start-turn"); + if (failure?.action === "retry") { + return false; + } + if (failure?.action === "restore") { + return restoreQueuedMessage(persistedMessage, failure.message); + } + acknowledgedExistingThreadMessageIdsRef.current.add(persistedMessage.messageId); + const delivered = + (await completeQueuedMessageDelivery(persistedMessage, deliveryRevision)) === "removed"; + if (delivered) { + acknowledgedExistingThreadMessageIdsRef.current.delete(persistedMessage.messageId); + // The delivered turn holds its own copy of the bytes. A failed delete + // is surfaced (never fails the delivered turn); the server also + // expires leaked pending uploads. + await prepared.releaseUploads().catch((error) => { + console.warn("[thread-outbox] could not delete consumed pending uploads", error); + }); + } + return delivered; }, [ makeDeliveryHelpers, @@ -242,6 +759,8 @@ export function useThreadOutboxDrain(): void { setThreadRuntimeMode, startTurn, updateThreadMetadata, + restoreQueuedMessage, + serverConfigs, ], ); @@ -255,7 +774,41 @@ export function useThreadOutboxDrain(): void { if (modelSelection === undefined) { return false; } - const { completeDelivery } = makeDeliveryHelpers(queuedMessage); + let prepared: PreparedTurnAttachments; + let persistedMessage: QueuedThreadMessage; + let deliveryRevision: number; + try { + const preparedResult = await prepareQueuedMessageAttachments( + queuedMessage, + serverConfigs.get(queuedMessage.environmentId)?.environment.capabilities + .attachmentUploads === true, + ); + if (preparedResult.status === "abandoned") { + return true; + } + prepared = preparedResult.prepared; + persistedMessage = preparedResult.persistedMessage; + deliveryRevision = preparedResult.deliveryRevision; + if (appAtomRegistry.get(editingQueuedMessageIdsAtom)[queuedMessage.messageId]) { + await preserveUploadedAttachmentsForEditor( + queuedMessage, + preparedResult.persistedMessage, + ); + return true; + } + } catch (error) { + console.warn("[thread-outbox] failed to upload attachments", error); + if (!shouldRetryThreadOutboxDelivery(error)) { + return restoreQueuedMessage( + queuedMessage, + error instanceof Error ? error.message : "An attachment could not upload.", + ); + } + return false; + } + if (!isQueuedMessagePayloadCurrent(persistedMessage, deliveryRevision)) { + return true; + } const deliveryResult = await startTurn({ environmentId: queuedMessage.environmentId, input: buildProjectThreadStartTurnInput({ @@ -267,6 +820,7 @@ export function useThreadOutboxDrain(): void { createdAt: queuedMessage.createdAt, text: queuedMessage.text.trim(), attachments: queuedMessage.attachments, + uploadedAttachments: prepared.attachments, modelSelection, runtimeMode: queuedMessage.runtimeMode ?? DEFAULT_RUNTIME_MODE, interactionMode: queuedMessage.interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE, @@ -277,9 +831,35 @@ export function useThreadOutboxDrain(): void { worktreeBranchName: buildTemporaryWorktreeBranchName(randomHex), }), }); - return completeDelivery(deliveryResult); + const { reportFailure } = makeDeliveryHelpers(queuedMessage); + const failure = reportFailure(deliveryResult, "start-turn"); + if (failure?.action === "retry") { + return false; + } + if (failure?.action === "restore") { + return restoreQueuedMessage(persistedMessage, failure.message); + } + const outcome = await completeQueuedMessageDelivery(persistedMessage, deliveryRevision); + if (outcome === "edited") { + if (appAtomRegistry.get(editingQueuedMessageIdsAtom)[queuedMessage.messageId]) { + // The editor holds the entry with unsaved edits; merging the queue + // payload now would duplicate the delivered turn. Once the editor + // saves, the duplicate-creation removal below recovers the edits. + return true; + } + // The thread exists now, so the next drain would remove the edited + // payload as a duplicate creation. Hand it to the thread's composer. + return recoverEditedCreationAfterDelivery(persistedMessage); + } + if (outcome === "removed") { + await prepared.releaseUploads().catch((error) => { + console.warn("[thread-outbox] could not delete consumed pending uploads", error); + }); + return true; + } + return false; }, - [makeDeliveryHelpers, startTurn], + [makeDeliveryHelpers, restoreQueuedMessage, serverConfigs, startTurn], ); useEffect(() => { @@ -287,14 +867,63 @@ export function useThreadOutboxDrain(): void { return; } + const queuedMessageIds = new Set( + Object.values(queuedMessagesByThreadKey) + .flat() + .map((message) => message.messageId), + ); + for (const messageId of acknowledgedExistingThreadMessageIdsRef.current) { + if (!queuedMessageIds.has(messageId)) { + acknowledgedExistingThreadMessageIdsRef.current.delete(messageId); + } + } + for (const [threadKey, queuedMessages] of Object.entries(queuedMessagesByThreadKey)) { const nextQueuedMessage = queuedMessages[0]; if (!nextQueuedMessage) { continue; } + if ( + nextQueuedMessage.creation === undefined && + acknowledgedExistingThreadMessageIdsRef.current.has(nextQueuedMessage.messageId) + ) { + if ((retryNotBeforeRef.current.get(nextQueuedMessage.messageId) ?? 0) > Date.now()) { + continue; + } + beginDispatchingQueuedMessage(nextQueuedMessage.messageId); + void removeAcknowledgedExistingThreadMessage( + nextQueuedMessage, + acknowledgedExistingThreadMessageIdsRef.current, + ) + .then((removed) => { + if (!removed) { + scheduleQueuedMessageRetry(nextQueuedMessage.messageId); + return; + } + retryAttemptRef.current.delete(nextQueuedMessage.messageId); + retryNotBeforeRef.current.delete(nextQueuedMessage.messageId); + const pendingTimer = retryTimersRef.current.get(nextQueuedMessage.messageId); + if (pendingTimer !== undefined) { + clearTimeout(pendingTimer); + retryTimersRef.current.delete(nextQueuedMessage.messageId); + } + }) + .finally(() => finishDispatchingQueuedMessage(nextQueuedMessage.messageId)); + return; + } if (editingQueuedMessageIds[nextQueuedMessage.messageId]) { continue; } + const blockedRecovery = blockedRecoverySubscriptionsRef.current.get( + nextQueuedMessage.messageId, + ); + if (blockedRecovery) { + if (blockedRecovery.message === nextQueuedMessage) { + continue; + } + blockedRecoverySubscriptionsRef.current.delete(nextQueuedMessage.messageId); + blockedRecovery.unsubscribe(); + } if ((retryNotBeforeRef.current.get(nextQueuedMessage.messageId) ?? 0) > Date.now()) { continue; } @@ -316,9 +945,53 @@ export function useThreadOutboxDrain(): void { environmentConnected: environment?.connectionState === "connected", threadBusy: thread?.session?.status === "running" || thread?.session?.status === "starting", }); - if (deliveryAction === "wait") { + // The delivery action resolves first; the file-capability gate applies + // only to a message that will send. Gating earlier would restore a + // creation whose startTurn already made the thread as a duplicate draft + // instead of removing it. + const serverConfig = serverConfigs.get(nextQueuedMessage.environmentId); + const dispatchStep = resolveThreadOutboxDispatchStep({ + deliveryAction, + fileAttachments: nextQueuedMessage.attachments.filter( + (attachment) => attachment.type === "file", + ), + serverConfig: serverConfig + ? { + maxFileUploadBytes: + serverConfig.environment.capabilities.fileAttachments?.maxUploadBytes, + } + : null, + }); + if (dispatchStep.step === "wait") { + continue; + } + if (dispatchStep.step === "retry") { + // The environment is connected but its config has not synced yet. + // Back off and retry instead of parking the message forever. + scheduleQueuedMessageRetry(nextQueuedMessage.messageId); continue; } + if (dispatchStep.step === "restore") { + const attachmentError = dispatchStep.reason; + beginDispatchingQueuedMessage(nextQueuedMessage.messageId); + void confirmThreadOutboxMessageQueued(nextQueuedMessage) + .then((queued) => { + if ( + !queued || + appAtomRegistry.get(editingQueuedMessageIdsAtom)[nextQueuedMessage.messageId] + ) { + return true; + } + return restoreQueuedMessage(nextQueuedMessage, attachmentError); + }) + .then((restored) => { + if (!restored) { + scheduleQueuedMessageRetry(nextQueuedMessage.messageId); + } + }) + .finally(() => finishDispatchingQueuedMessage(nextQueuedMessage.messageId)); + return; + } // The live project shell is preferred for the workspace path, with the // snapshot taken at enqueue time as the fallback so a task never dies // just because its project shell is not loaded. @@ -368,8 +1041,36 @@ export function useThreadOutboxDrain(): void { if (appAtomRegistry.get(editingQueuedMessageIdsAtom)[nextQueuedMessage.messageId]) { return true; } + // The shell state is equally stale. Re-run the same delivery policy + // against the live thread snapshot so a vanished thread or newly + // created target defers, while busy existing threads can still steer. + if (deliveryAction === "send") { + const liveThread = findThread( + appAtomRegistry.get(environmentThreadShells.threadShellsAtom), + nextQueuedMessage, + ); + const liveThreadBusy = + liveThread?.session?.status === "running" || liveThread?.session?.status === "starting"; + const liveDeliveryAction = resolveThreadOutboxDeliveryAction({ + isCreation: creation !== undefined, + threadExists: liveThread !== undefined, + shellStatus, + environmentConnected: environment?.connectionState === "connected", + threadBusy: liveThreadBusy, + }); + if (liveDeliveryAction !== "send") { + return true; + } + } return deliveryAction === "remove" - ? removeQueuedMessage("[thread-outbox] failed to remove message for a missing thread") + ? creation !== undefined + ? // A creation entry that survived its delivery cleanup either + // holds edits (recover them) or the delivered payload (a + // recovered duplicate the user can delete). Restart loses any + // in-memory distinction, and losing edits is the worse failure, + // so recovery is unconditional here. + recoverEditedCreationAfterDelivery(nextQueuedMessage) + : removeQueuedMessage("[thread-outbox] failed to remove message for a missing thread") : creation !== undefined ? creationProjectCwd !== null ? sendQueuedCreation(nextQueuedMessage, creation, creationProjectCwd) @@ -391,19 +1092,7 @@ export function useThreadOutboxDrain(): void { return; } - const retryAttempt = (retryAttemptRef.current.get(nextQueuedMessage.messageId) ?? 0) + 1; - retryAttemptRef.current.set(nextQueuedMessage.messageId, retryAttempt); - const retryDelayMs = threadOutboxRetryDelayMs(retryAttempt); - retryNotBeforeRef.current.set(nextQueuedMessage.messageId, Date.now() + retryDelayMs); - const pendingTimer = retryTimersRef.current.get(nextQueuedMessage.messageId); - if (pendingTimer !== undefined) { - clearTimeout(pendingTimer); - } - const retryTimer = setTimeout(() => { - retryTimersRef.current.delete(nextQueuedMessage.messageId); - setRetryTick((current) => current + 1); - }, retryDelayMs); - retryTimersRef.current.set(nextQueuedMessage.messageId, retryTimer); + scheduleQueuedMessageRetry(nextQueuedMessage.messageId); }) .finally(() => { finishDispatchingQueuedMessage(nextQueuedMessage.messageId); @@ -417,8 +1106,11 @@ export function useThreadOutboxDrain(): void { projects, queuedMessagesByThreadKey, retryTick, + restoreQueuedMessage, + scheduleQueuedMessageRetry, sendQueuedCreation, sendQueuedMessage, + serverConfigs, shellStatuses, threads, ]); diff --git a/apps/mobile/src/state/use-thread-pr.test.ts b/apps/mobile/src/state/use-thread-pr.test.ts index a355dddcd43d..1313f202fa13 100644 --- a/apps/mobile/src/state/use-thread-pr.test.ts +++ b/apps/mobile/src/state/use-thread-pr.test.ts @@ -17,7 +17,7 @@ describe("presentThreadPr", () => { expect(presentThreadPr(pullRequest, undefined)).toMatchObject({ label: "3774", accessibilityLabel: "#3774 pull request merged", - textClassName: "text-violet-600 dark:text-violet-400", + textClassName: "text-adaptive-violet-600-400", }); }); diff --git a/apps/mobile/src/state/use-thread-pr.ts b/apps/mobile/src/state/use-thread-pr.ts index a3440cd4848e..0c10d7b3fa41 100644 --- a/apps/mobile/src/state/use-thread-pr.ts +++ b/apps/mobile/src/state/use-thread-pr.ts @@ -1,9 +1,16 @@ import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; +import { + createLinkedPullRequestDetailAtomFamily, + pullRequestDetailToVcsStatus, +} from "@t3tools/client-runtime/state/pull-requests"; +import { connectionAtomRuntime } from "../connection/runtime"; import { useEnvironmentQuery } from "./query"; import { presentThreadPr, type ThreadPrPresentation } from "./thread-pr-presentation"; import { vcsEnvironment } from "./vcs"; +const linkedPullRequestDetailAtom = createLinkedPullRequestDetailAtomFamily(connectionAtomRuntime); + export { presentThreadPr, type ThreadPr, @@ -22,13 +29,36 @@ export function useThreadPr( ): ThreadPrPresentation | null { const cwd = thread.worktreePath ?? projectCwd; const gitStatus = useEnvironmentQuery( - thread.branch !== null && cwd !== null + thread.linkedPullRequest == null && thread.branch !== null && cwd !== null ? vcsEnvironment.status({ environmentId: thread.environmentId, input: { cwd }, }) : null, ); + const linkedPullRequest = useEnvironmentQuery( + thread.linkedPullRequest == null + ? null + : linkedPullRequestDetailAtom({ + environmentId: thread.environmentId, + input: { + projectId: thread.linkedPullRequest.projectId, + repository: thread.linkedPullRequest.repository, + number: thread.linkedPullRequest.number, + }, + }), + ); + + if (thread.linkedPullRequest != null) { + const detail = linkedPullRequest.data; + return detail === null + ? null + : presentThreadPr(pullRequestDetailToVcsStatus(detail), { + kind: detail.provider, + name: detail.provider, + baseUrl: "", + }); + } const status = gitStatus.data; if (status === null || thread.branch === null || status.refName !== thread.branch) { diff --git a/apps/mobile/src/state/use-thread-selection.ts b/apps/mobile/src/state/use-thread-selection.ts index 8e340ef33bd4..e0e87d609d5f 100644 --- a/apps/mobile/src/state/use-thread-selection.ts +++ b/apps/mobile/src/state/use-thread-selection.ts @@ -54,6 +54,7 @@ function threadDetailToShell( interactionMode: thread.interactionMode, branch: thread.branch, worktreePath: thread.worktreePath, + linkedPullRequest: thread.linkedPullRequest ?? null, latestTurn: thread.latestTurn, createdAt: thread.createdAt, updatedAt: thread.updatedAt, diff --git a/apps/mobile/uniwind-types.d.ts b/apps/mobile/uniwind-types.d.ts index cc099419a9b9..22856ab57ffa 100644 --- a/apps/mobile/uniwind-types.d.ts +++ b/apps/mobile/uniwind-types.d.ts @@ -3,7 +3,7 @@ declare module 'uniwind' { export interface UniwindConfig { - themes: readonly ['light', 'dark'] + themes: readonly ['light', 'dark', 't3-chat-light', 't3-chat-dark', 'grove-light', 'grove-dark', 'ocean-light', 'ocean-dark', 'ember-light', 'ember-dark', 'iris-light', 'iris-dark'] } } diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index f332b080ceea..c4e1c62ef50c 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -29,7 +29,7 @@ import { OrchestrationCommandReceiptRepositoryLive } from "../src/persistence/La import { OrchestrationEventStoreLive } from "../src/persistence/Layers/OrchestrationEventStore.ts"; import { ProjectionCheckpointRepositoryLive } from "../src/persistence/Layers/ProjectionCheckpoints.ts"; import { ProjectionPendingApprovalRepositoryLive } from "../src/persistence/Layers/ProjectionPendingApprovals.ts"; -import { ProviderSessionRuntimeRepositoryLive } from "../src/persistence/Layers/ProviderSessionRuntime.ts"; +import * as ProviderSessionRuntime from "../src/persistence/ProviderSessionRuntime.ts"; import { makeSqlitePersistenceLive } from "../src/persistence/Layers/Sqlite.ts"; import { ProjectionCheckpointRepository } from "../src/persistence/Services/ProjectionCheckpoints.ts"; import { ProjectionPendingApprovalRepository } from "../src/persistence/Services/ProjectionPendingApprovals.ts"; @@ -45,7 +45,7 @@ import { ProviderEventLoggers, } from "../src/provider/Layers/ProviderEventLoggers.ts"; import { ProviderService } from "../src/provider/Services/ProviderService.ts"; -import { AnalyticsService } from "../src/telemetry/Services/AnalyticsService.ts"; +import { AnalyticsService } from "../src/telemetry/AnalyticsService.ts"; import { CheckpointReactorLive } from "../src/orchestration/Layers/CheckpointReactor.ts"; import * as RepositoryIdentityResolver from "../src/project/RepositoryIdentityResolver.ts"; import { OrchestrationEngineLive } from "../src/orchestration/Layers/OrchestrationEngine.ts"; @@ -64,6 +64,7 @@ import { type OrchestrationEngineShape, } from "../src/orchestration/Services/OrchestrationEngine.ts"; import { ThreadDeletionReactor } from "../src/orchestration/Services/ThreadDeletionReactor.ts"; +import * as ThreadSettlementReactor from "../src/orchestration/ThreadSettlementReactor.ts"; import { OrchestrationReactor } from "../src/orchestration/Services/OrchestrationReactor.ts"; import { ProjectionSnapshotQuery } from "../src/orchestration/Services/ProjectionSnapshotQuery.ts"; import { @@ -270,7 +271,7 @@ export const makeOrchestrationIntegrationHarness = ( Layer.provide(OrchestrationCommandReceiptRepositoryLive), ); const providerSessionDirectoryLayer = ProviderSessionDirectoryLive.pipe( - Layer.provide(ProviderSessionRuntimeRepositoryLive), + Layer.provide(ProviderSessionRuntime.layer), ); const realCodexRegistry = Layer.effect( ProviderAdapterRegistry, @@ -372,6 +373,12 @@ export const makeOrchestrationIntegrationHarness = ( Layer.provideMerge(checkpointReactorLayer), Layer.provideMerge( Layer.succeed(ThreadDeletionReactor, { + start: () => Effect.void, + drainThrough: () => Effect.void, + }), + ), + Layer.provideMerge( + Layer.succeed(ThreadSettlementReactor.ThreadSettlementReactor, { start: () => Effect.void, drain: Effect.void, }), diff --git a/apps/server/integration/providerService.integration.test.ts b/apps/server/integration/providerService.integration.test.ts index 6089d22d9aa3..3ad85b1a68b9 100644 --- a/apps/server/integration/providerService.integration.test.ts +++ b/apps/server/integration/providerService.integration.test.ts @@ -25,7 +25,7 @@ import { } from "../src/provider/Services/ProviderService.ts"; import * as ServerConfig from "../src/config.ts"; import { ServerSettingsService } from "../src/serverSettings.ts"; -import { AnalyticsService } from "../src/telemetry/Services/AnalyticsService.ts"; +import { AnalyticsService } from "../src/telemetry/AnalyticsService.ts"; import { SqlitePersistenceMemory } from "../src/persistence/Layers/Sqlite.ts"; import * as ProviderSessionRuntime from "../src/persistence/ProviderSessionRuntime.ts"; diff --git a/apps/server/package.json b/apps/server/package.json index eb4dc7dd35ec..073008d917a5 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -1,6 +1,6 @@ { "name": "t3", - "version": "0.0.33", + "version": "0.0.38", "license": "MIT", "repository": { "type": "git", diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index bc7828dd8547..e51a8883f4f9 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -19,7 +19,15 @@ const emitInterleavedAssistantToolCalls = const emitGenericToolPlaceholders = process.env.T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS === "1"; const emitAskQuestion = process.env.T3_ACP_EMIT_ASK_QUESTION === "1"; const emitXAiAskUserQuestion = process.env.T3_ACP_EMIT_XAI_ASK_USER_QUESTION === "1"; +const emitXAiExitPlanMode = process.env.T3_ACP_EMIT_XAI_EXIT_PLAN_MODE === "1"; +const emitXAiPlanMdWrite = process.env.T3_ACP_EMIT_XAI_PLAN_MD_WRITE === "1"; const emitXAiPromptCompleteThenHang = process.env.T3_ACP_EMIT_XAI_PROMPT_COMPLETE_THEN_HANG === "1"; +const emitXAiRateLimitThenHang = process.env.T3_ACP_EMIT_XAI_RATE_LIMIT_THEN_HANG === "1"; +const emitXAiAskUserQuestionThenHang = + process.env.T3_ACP_EMIT_XAI_ASK_USER_QUESTION_THEN_HANG === "1"; +const emitContentThenHang = process.env.T3_ACP_EMIT_CONTENT_THEN_HANG === "1"; +const emitPlanThenHang = process.env.T3_ACP_EMIT_PLAN_THEN_HANG === "1"; +const emitActiveToolThenHang = process.env.T3_ACP_EMIT_ACTIVE_TOOL_THEN_HANG === "1"; const emitForeignSessionUpdates = process.env.T3_ACP_EMIT_FOREIGN_SESSION_UPDATES === "1"; const hangPromptForever = process.env.T3_ACP_HANG_PROMPT_FOREVER === "1"; const hangFirstPromptForever = process.env.T3_ACP_HANG_FIRST_PROMPT_FOREVER === "1"; @@ -39,12 +47,19 @@ const failPrompt = process.env.T3_ACP_FAIL_PROMPT === "1"; const failSetConfigOption = process.env.T3_ACP_FAIL_SET_CONFIG_OPTION === "1"; const exitOnSetConfigOption = process.env.T3_ACP_EXIT_ON_SET_CONFIG_OPTION === "1"; const promptResponseText = process.env.T3_ACP_PROMPT_RESPONSE_TEXT; +const initialGrokReasoningEffort = + process.env.T3_ACP_INITIAL_GROK_REASONING_EFFORT?.trim() || undefined; const promptDelayMs = Number(process.env.T3_ACP_PROMPT_DELAY_MS ?? "0"); const permissionOptionIds = { allowOnce: process.env.T3_ACP_ALLOW_ONCE_OPTION_ID ?? "allow-once", allowAlways: process.env.T3_ACP_ALLOW_ALWAYS_OPTION_ID ?? "allow-always", rejectOnce: process.env.T3_ACP_REJECT_ONCE_OPTION_ID ?? "reject-once", }; +const omitAllowAlways = process.env.T3_ACP_OMIT_ALLOW_ALWAYS === "1"; +const permissionRequestCount = Math.max( + 1, + Number(process.env.T3_ACP_PERMISSION_REQUEST_COUNT ?? "1") || 1, +); const sessionId = "mock-session-1"; let currentModeId = "ask"; @@ -279,7 +294,13 @@ function modeState(): AcpSchema.SessionModeState { } const grokAcpModels: ReadonlyArray = [ - { modelId: "grok-build", name: "Grok Build" }, + { + modelId: "grok-build", + name: "Grok Build", + ...(initialGrokReasoningEffort + ? { _meta: { reasoningEffort: initialGrokReasoningEffort } } + : {}), + }, { modelId: "grok-mock-alt", name: "Grok Mock Alt" }, ]; @@ -522,6 +543,68 @@ const program = Effect.gen(function* () { return yield* Effect.never; } + if (emitXAiRateLimitThenHang) { + writeJsonRpcNotification("_x.ai/session/prompt_complete", { + sessionId: requestedSessionId, + promptId: promptIdFromRequestMeta(request) ?? "mock-xai-rate-limit-prompt-1", + stopReason: "rate_limit", + agentResult: null, + }); + return yield* Effect.never; + } + + if (emitContentThenHang) { + yield* agent.client.sessionUpdate({ + sessionId: requestedSessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "partial before stall" }, + }, + }); + return yield* Effect.never; + } + + if (emitPlanThenHang) { + yield* agent.client.sessionUpdate({ + sessionId: requestedSessionId, + update: { + sessionUpdate: "plan", + entries: [ + { + content: "Wait for more ACP progress", + priority: "high", + status: "in_progress", + }, + ], + }, + }); + return yield* Effect.never; + } + + if (emitActiveToolThenHang) { + const toolCallId = "tool-call-long-running-1"; + yield* agent.client.sessionUpdate({ + sessionId: requestedSessionId, + update: { + sessionUpdate: "tool_call", + toolCallId, + title: "Long-running tool", + kind: "execute", + status: "pending", + rawInput: { command: ["long-running-tool"] }, + }, + }); + yield* agent.client.sessionUpdate({ + sessionId: requestedSessionId, + update: { + sessionUpdate: "tool_call_update", + toolCallId, + status: "in_progress", + }, + }); + return yield* Effect.never; + } + if (emitXAiPromptCompleteThenHang) { writeJsonRpcNotification("session/update", { sessionId: requestedSessionId, @@ -656,37 +739,58 @@ const program = Effect.gen(function* () { }, }); - const permission = yield* agent.client.requestPermission({ - sessionId: requestedSessionId, - toolCall: { - toolCallId, - title: "`cat server/package.json`", - kind: "execute", - status: "pending", - content: [ - { - type: "content", - content: { - type: "text", - text: "Not in allowlist: cat server/package.json", + const permissionOptions: Array = [ + { optionId: permissionOptionIds.allowOnce, name: "Allow once", kind: "allow_once" }, + ...(omitAllowAlways + ? [] + : [ + { + optionId: permissionOptionIds.allowAlways, + name: "Allow always", + kind: "allow_always" as const, }, + ]), + { optionId: permissionOptionIds.rejectOnce, name: "Reject", kind: "reject_once" }, + ]; + + let cancelled = cancelledSessions.delete(requestedSessionId); + for (let index = 0; index < permissionRequestCount; index++) { + const command = + index > 0 + ? (process.env.T3_ACP_SECOND_PERMISSION_COMMAND ?? "cat server/package.json") + : "cat server/package.json"; + const permission = yield* agent.client.requestPermission({ + sessionId: requestedSessionId, + toolCall: { + toolCallId: index === 0 ? toolCallId : `${toolCallId}-${index + 1}`, + title: process.env.T3_ACP_PERMISSION_TITLE ?? `\`${command}\``, + kind: "execute", + status: "pending", + rawInput: { + variant: "Bash", + command, + description: index === 0 ? "Read package metadata" : "Read it again", }, - ], - }, - options: [ - { optionId: permissionOptionIds.allowOnce, name: "Allow once", kind: "allow_once" }, - { - optionId: permissionOptionIds.allowAlways, - name: "Allow always", - kind: "allow_always", + content: [ + { + type: "content", + content: { + type: "text", + text: `Not in allowlist: ${command}`, + }, + }, + ], }, - { optionId: permissionOptionIds.rejectOnce, name: "Reject", kind: "reject_once" }, - ], - }); - - const cancelled = - cancelledSessions.delete(requestedSessionId) || - permission.outcome.outcome === "cancelled"; + options: permissionOptions, + }); + cancelled = + cancelled || + cancelledSessions.delete(requestedSessionId) || + permission.outcome.outcome === "cancelled"; + if (cancelled) { + break; + } + } yield* agent.client.sessionUpdate({ sessionId: requestedSessionId, @@ -773,7 +877,7 @@ const program = Effect.gen(function* () { return { stopReason: "end_turn" }; } - if (emitXAiAskUserQuestion) { + if (emitXAiAskUserQuestion || emitXAiAskUserQuestionThenHang) { const result = yield* agent.client.extRequest("_x.ai/ask_user_question", { method: "x.ai/ask_user_question", params: { @@ -807,6 +911,84 @@ const program = Effect.gen(function* () { throw new Error("Expected accepted _x.ai/ask_user_question response answers."); } + if (emitXAiAskUserQuestionThenHang) { + return yield* Effect.never; + } + + return { stopReason: "end_turn" }; + } + + if (emitXAiPlanMdWrite) { + // Match Grok's real session layout so isGrokPlanMarkdownPath accepts it. + const planRoot = process.env.T3_ACP_PLAN_ROOT ?? "/tmp/mock-home/.grok"; + const planPath = `${planRoot}/sessions/${requestedSessionId}/plan.md`; + const planBody = "# Mock plan\n\n- Write the feature\n- Add a test\n- Ship it\n"; + // enter_plan_mode first so the adapter arms planModeActive. + yield* agent.client.sessionUpdate({ + sessionId: requestedSessionId, + update: { + sessionUpdate: "tool_call", + toolCallId: "enter-plan-mode-1", + title: "enter_plan_mode", + kind: "other", + status: "completed", + rawInput: { variant: "EnterPlanMode" }, + }, + }); + yield* agent.client.sessionUpdate({ + sessionId: requestedSessionId, + update: { + sessionUpdate: "tool_call", + toolCallId: "plan-md-write-1", + title: "write", + kind: "edit", + status: "pending", + rawInput: { file_path: planPath, content: planBody }, + }, + }); + yield* agent.client.sessionUpdate({ + sessionId: requestedSessionId, + update: { + sessionUpdate: "tool_call_update", + toolCallId: "plan-md-write-1", + kind: "edit", + status: "completed", + title: `Write \`${planPath}\``, + rawInput: { file_path: planPath, content: planBody }, + content: [ + { + type: "diff", + path: planPath, + oldText: "", + newText: planBody, + }, + ], + }, + }); + return { stopReason: "end_turn" }; + } + + if (emitXAiExitPlanMode) { + const result = yield* agent.client.extRequest("_x.ai/exit_plan_mode", { + method: "x.ai/exit_plan_mode", + params: { + sessionId: requestedSessionId, + toolCallId: "exit-plan-mode-tool-call-1", + planContent: "# Exit plan\n\n- Step one\n- Step two\n", + }, + }); + if (typeof result !== "object" || result === null || !("outcome" in result)) { + throw new Error("Expected _x.ai/exit_plan_mode response outcome."); + } + if ( + result.outcome !== "abandoned" && + result.outcome !== "approved" && + result.outcome !== "request_changes" + ) { + throw new Error( + `Expected exit_plan_mode outcome abandoned|approved|request_changes, got ${String(result.outcome)}`, + ); + } return { stopReason: "end_turn" }; } diff --git a/apps/server/scripts/cliErrors.test.ts b/apps/server/scripts/cliErrors.test.ts deleted file mode 100644 index 91754290db9a..000000000000 --- a/apps/server/scripts/cliErrors.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { assert, describe, it } from "@effect/vitest"; - -import { ServerCliBuildAssetMissingError, ServerCliCommandExitError } from "./cliErrors.ts"; - -describe("server CLI errors", () => { - it("preserves failed command context without changing its message", () => { - const error = new ServerCliCommandExitError({ - command: "vp", - args: ["pm", "publish"], - cwd: "/repo", - exitCode: 17, - }); - - assert.equal(error._tag, "ServerCliCommandExitError"); - assert.equal(error.command, "vp"); - assert.deepEqual(error.args, ["pm", "publish"]); - assert.equal(error.cwd, "/repo"); - assert.equal(error.exitCode, 17); - assert.equal(error.message, "Command exited with non-zero exit code (17)"); - }); - - it("preserves a representative missing asset path", () => { - const error = new ServerCliBuildAssetMissingError({ assetPath: "/repo/server.mjs" }); - - assert.equal(error.assetPath, "/repo/server.mjs"); - assert.equal( - error.message, - "Missing build asset: /repo/server.mjs. Run the build subcommand first.", - ); - }); -}); diff --git a/apps/server/scripts/cursor-acp-model-mismatch-probe.ts b/apps/server/scripts/cursor-acp-model-mismatch-probe.ts deleted file mode 100644 index 7e4e88aeb2b4..000000000000 --- a/apps/server/scripts/cursor-acp-model-mismatch-probe.ts +++ /dev/null @@ -1,442 +0,0 @@ -// @effect-diagnostics nodeBuiltinImport:off -import * as NodeChildProcess from "node:child_process"; -import * as NodeProcess from "node:process"; -import * as NodeReadline from "node:readline"; -import * as NodeTimers from "node:timers"; -import { resolveSpawnCommand } from "@t3tools/shared/shell"; -import * as Effect from "effect/Effect"; - -type JsonPrimitive = null | boolean | number | string; -type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue }; - -type JsonRpcId = number | string; - -type JsonRpcMessage = { - jsonrpc?: string; - id?: JsonRpcId; - method?: string; - params?: JsonValue; - result?: JsonValue; - error?: JsonValue; - headers?: JsonValue; -}; - -type SelectLeafOption = { - value: string; - label?: string; - name?: string; -}; - -type SelectGroupOption = { - label?: string; - name?: string; - options: SelectLeafOption[]; -}; - -type SessionConfigOption = { - id: string; - name?: string; - category?: string; - type?: string; - options?: Array; -}; - -type SessionNewResult = { - sessionId: string; - configOptions?: SessionConfigOption[]; -}; - -type SetConfigResult = { - configOptions?: SessionConfigOption[]; -}; - -type PendingRequest = { - method: string; - resolve: (value: JsonValue | undefined) => void; - reject: (error: Error) => void; -}; - -const targetCwd = NodeProcess.argv[2] ?? NodeProcess.cwd(); -const targetModel = NodeProcess.argv[3] ?? "gpt-5.4"; -const promptText = NodeProcess.argv[4] ?? "helo"; -const targetReasoning = NodeProcess.env.CURSOR_REASONING ?? ""; -const targetContext = NodeProcess.env.CURSOR_CONTEXT ?? ""; -const targetFast = NodeProcess.env.CURSOR_FAST ?? ""; -const agentBin = NodeProcess.env.CURSOR_AGENT_BIN ?? "cursor-agent"; -const promptWaitMs = Number(NodeProcess.env.CURSOR_PROMPT_WAIT_MS ?? "4000"); -const requestTimeoutMs = Number(NodeProcess.env.CURSOR_REQUEST_TIMEOUT_MS ?? "20000"); - -function logSection(title: string, value: unknown) { - NodeProcess.stdout.write(`\n=== ${title} ===\n`); - NodeProcess.stdout.write(`${JSON.stringify(value, null, 2)}\n`); -} - -function fail(message: string): never { - throw new Error(message); -} - -function asString(value: JsonValue | undefined): string | null { - return typeof value === "string" ? value : null; -} - -function flattenSelectValues(option: SessionConfigOption | undefined): string[] { - if (!option || option.type !== "select" || !Array.isArray(option.options)) { - return []; - } - - const values: string[] = []; - for (const entry of option.options) { - if (!entry || typeof entry !== "object") { - continue; - } - if ("value" in entry && typeof entry.value === "string") { - values.push(entry.value); - continue; - } - if ("options" in entry && Array.isArray(entry.options)) { - for (const nested of entry.options) { - if (nested && typeof nested === "object" && typeof nested.value === "string") { - values.push(nested.value); - } - } - } - } - return values; -} - -function findConfigOption( - configOptions: SessionConfigOption[], - predicate: (option: SessionConfigOption) => boolean, -): SessionConfigOption | undefined { - return configOptions.find(predicate); -} - -function matchesKeyword(option: SessionConfigOption, keyword: string): boolean { - const haystack = `${option.id} ${option.name ?? ""}`.toLowerCase(); - return haystack.includes(keyword.toLowerCase()); -} - -function sleep(ms: number) { - return new Promise((resolve) => { - // @effect-diagnostics-next-line globalTimers:off - Standalone Node probe script, not an Effect runtime test. - NodeTimers.setTimeout(resolve, ms); - }); -} - -class JsonRpcChild { - readonly child: NodeChildProcess.ChildProcessWithoutNullStreams; - readonly pending = new Map(); - nextId = 1; - closed = false; - - constructor(bin: string, args: string[], cwd: string) { - const spawnCommand = Effect.runSync(resolveSpawnCommand(bin, args)); - this.child = NodeChildProcess.spawn(spawnCommand.command, spawnCommand.args, { - cwd, - shell: spawnCommand.shell, - stdio: ["pipe", "pipe", "pipe"], - env: NodeProcess.env, - }); - - this.child.on("exit", (code, signal) => { - this.closed = true; - const detail = `ACP process exited (code=${String(code)}, signal=${String(signal)})`; - for (const pending of this.pending.values()) { - pending.reject(new Error(`${detail} while waiting for ${pending.method}`)); - } - this.pending.clear(); - }); - - this.child.on("error", (error) => { - this.closed = true; - for (const pending of this.pending.values()) { - pending.reject(error); - } - this.pending.clear(); - }); - - const stdout = NodeReadline.createInterface({ input: this.child.stdout }); - stdout.on("line", (line) => { - void this.handleStdoutLine(line); - }); - - const stderr = NodeReadline.createInterface({ input: this.child.stderr }); - stderr.on("line", (line) => { - NodeProcess.stdout.write(`[stderr] ${line}\n`); - }); - } - - write(message: JsonRpcMessage) { - if (this.closed) { - fail("ACP process is already closed."); - } - const payload = JSON.stringify({ - jsonrpc: "2.0", - headers: [], - ...message, - }); - NodeProcess.stdout.write(`>>> ${payload}\n`); - this.child.stdin.write(`${payload}\n`); - } - - async request(method: string, params: JsonValue, timeoutMs = requestTimeoutMs) { - const id = this.nextId++; - - const responsePromise = new Promise((resolve, reject) => { - // @effect-diagnostics-next-line globalTimers:off - Standalone Node probe script request timeout. - const timeout = NodeTimers.setTimeout(() => { - this.pending.delete(id); - reject(new Error(`Timed out waiting for ${method} response after ${timeoutMs}ms.`)); - }, timeoutMs); - - this.pending.set(id, { - method, - resolve: (value) => { - NodeTimers.clearTimeout(timeout); - resolve(value); - }, - reject: (error) => { - NodeTimers.clearTimeout(timeout); - reject(error); - }, - }); - }); - - this.write({ - id, - method, - params, - }); - - return responsePromise; - } - - notify(method: string, params: JsonValue) { - this.write({ - method, - params, - }); - } - - respond(id: JsonRpcId, result: JsonValue) { - this.write({ - id, - result, - }); - } - - respondError(id: JsonRpcId, code: number, message: string) { - this.write({ - id, - error: { - code, - message, - }, - }); - } - - async handleStdoutLine(line: string) { - if (line.trim().length === 0) { - return; - } - - NodeProcess.stdout.write(`<<< ${line}\n`); - - let message: JsonRpcMessage; - try { - message = JSON.parse(line) as JsonRpcMessage; - } catch (error) { - NodeProcess.stdout.write(`[parse-error] ${(error as Error).message}\n`); - return; - } - - if (typeof message.id !== "undefined" && !message.method) { - const pending = this.pending.get(message.id); - if (!pending) { - return; - } - this.pending.delete(message.id); - if (typeof message.error !== "undefined") { - pending.reject( - new Error(`RPC ${pending.method} failed: ${JSON.stringify(message.error, null, 2)}`), - ); - return; - } - pending.resolve(message.result); - return; - } - - if (message.method === "session/request_permission" && typeof message.id !== "undefined") { - this.respond(message.id, { - outcome: { - outcome: "selected", - optionId: "allow", - }, - }); - return; - } - - if (typeof message.id !== "undefined" && message.id !== "") { - this.respondError( - message.id, - -32601, - `Unhandled server request: ${message.method ?? "unknown"}`, - ); - } - } - - async close() { - if (this.closed) { - return; - } - this.child.kill("SIGTERM"); - await sleep(250); - if (!this.closed) { - this.child.kill("SIGKILL"); - } - } -} - -async function setSelectOptionIfAdvertised( - rpc: JsonRpcChild, - sessionId: string, - configOptions: SessionConfigOption[], - predicate: (option: SessionConfigOption) => boolean, - value: string, - label: string, -) { - if (value.length === 0) { - return configOptions; - } - - const option = findConfigOption(configOptions, predicate); - const values = flattenSelectValues(option); - if (!option || !values.includes(value)) { - logSection(`SKIP_${label}`, { - requestedValue: value, - availableValues: values, - }); - return configOptions; - } - - const response = (await rpc.request("session/set_config_option", { - sessionId, - configId: option.id, - value, - })) as SetConfigResult | null | undefined; - - logSection(`SET_${label}_RESPONSE`, response); - return response?.configOptions ?? configOptions; -} - -async function main() { - const rpc = new JsonRpcChild(agentBin, ["acp"], targetCwd); - - try { - const initializeResponse = await rpc.request("initialize", { - protocolVersion: 1, - clientCapabilities: { - fs: { readTextFile: false, writeTextFile: false }, - terminal: false, - _meta: { - parameterizedModelPicker: true, - }, - }, - clientInfo: { - name: "cursor-acp-model-mismatch-probe", - version: "0.0.0", - }, - }); - logSection("INITIALIZE_RESPONSE", initializeResponse); - - const authenticateResponse = await rpc.request("authenticate", { - methodId: "cursor_login", - }); - logSection("AUTHENTICATE_RESPONSE", authenticateResponse); - - const sessionResponse = (await rpc.request("session/new", { - cwd: targetCwd, - mcpServers: [], - })) as SessionNewResult; - logSection("SESSION_NEW_RESPONSE", sessionResponse); - - const sessionId = asString(sessionResponse.sessionId); - if (!sessionId) { - fail("session/new did not return a sessionId."); - } - - let configOptions = sessionResponse.configOptions ?? []; - const modelConfig = findConfigOption(configOptions, (option) => option.category === "model"); - const advertisedModels = flattenSelectValues(modelConfig); - logSection("ADVERTISED_MODEL_VALUES", advertisedModels); - - if (!modelConfig || modelConfig.type !== "select") { - fail("Cursor ACP did not expose a select-type model config option."); - } - - if (!advertisedModels.includes(targetModel)) { - fail( - `Cursor ACP did not advertise model ${JSON.stringify(targetModel)}. Advertised values: ${advertisedModels.join(", ")}`, - ); - } - - const setModelResponse = (await rpc.request("session/set_config_option", { - sessionId, - configId: modelConfig.id, - value: targetModel, - })) as SetConfigResult | null | undefined; - logSection("SET_MODEL_RESPONSE", setModelResponse); - - configOptions = setModelResponse?.configOptions ?? configOptions; - - configOptions = await setSelectOptionIfAdvertised( - rpc, - sessionId, - configOptions, - (option) => option.category === "thought_level", - targetReasoning, - "REASONING", - ); - - configOptions = await setSelectOptionIfAdvertised( - rpc, - sessionId, - configOptions, - (option) => option.category === "model_config" && matchesKeyword(option, "context"), - targetContext, - "CONTEXT", - ); - - configOptions = await setSelectOptionIfAdvertised( - rpc, - sessionId, - configOptions, - (option) => option.category === "model_config" && matchesKeyword(option, "fast"), - targetFast, - "FAST", - ); - - const promptResponse = await rpc.request("session/prompt", { - sessionId, - prompt: [ - { - type: "text", - text: promptText, - }, - ], - }); - logSection("PROMPT_RESPONSE", promptResponse); - - await sleep(promptWaitMs); - rpc.notify("session/cancel", { sessionId }); - } finally { - await rpc.close(); - } -} - -void main().catch((error: unknown) => { - NodeProcess.stderr.write( - `${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n`, - ); - process.exitCode = 1; -}); diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts index aa47a78238bb..4a47a17fabc3 100644 --- a/apps/server/src/assets/AssetAccess.test.ts +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -1,4 +1,7 @@ +// @effect-diagnostics nodeBuiltinImport:off - tests inject swaps at the native open boundary. import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as NodeHttpPlatform from "@effect/platform-node/NodeHttpPlatform"; +import * as NodeFSP from "node:fs/promises"; import { AssetPreviewTypeValidationError, ThreadId } from "@t3tools/contracts"; import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon"; import { describe, expect, it } from "@effect/vitest"; @@ -9,18 +12,28 @@ import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; import * as TestClock from "effect/testing/TestClock"; +import { HttpServerResponse } from "effect/unstable/http"; +import { vi } from "vite-plus/test"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import * as ServerConfig from "../config.ts"; import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts"; import * as T3ProjectFileLoader from "../project/T3ProjectFileLoader.ts"; import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; +import { assetFileResponse } from "../http.ts"; import { ASSET_ROUTE_PREFIX, issueAssetUrl, resolveAsset } from "./AssetAccess.ts"; +import { openMediaFile } from "./MediaFile.ts"; + +vi.mock("node:fs/promises", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, open: vi.fn(actual.open), realpath: vi.fn(actual.realpath) }; +}); const configLayer = ServerConfig.ServerConfig.layerTest(process.cwd(), { prefix: "t3-asset-access-test-", }); const testLayer = Layer.mergeAll( + NodeHttpPlatform.layer, configLayer, WorkspacePaths.layer, ProjectFaviconResolver.layer.pipe( @@ -31,6 +44,328 @@ const testLayer = Layer.mergeAll( ).pipe(Layer.provideMerge(NodeServices.layer)); describe("AssetAccess", () => { + it.effect("issues exact URLs for images and videos outside the workspace", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-root-" }); + const outside = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-outside-" }); + for (const [name, mimeType] of [ + ["screenshot.png", "image/png"], + ["recording.mp4", "video/mp4"], + ["recording.webm", "video/webm"], + ] as const) { + const filePath = path.join(outside, name); + yield* fs.writeFileString(filePath, "media"); + const canonicalFile = yield* fs.realPath(filePath); + const result = yield* issueAssetUrl({ + resource: { _tag: "media-file", threadId: ThreadId.make("thread-1"), path: filePath }, + workspaceRoot: root, + }); + const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const separator = suffix.indexOf("/"); + const token = suffix.slice(0, separator); + expect(yield* resolveAsset(token, suffix.slice(separator + 1))).toMatchObject({ + kind: "file", + path: canonicalFile, + mimeType, + }); + yield* fs.writeFileString(path.join(outside, "sibling.png"), "private sibling"); + expect(yield* resolveAsset(token, "sibling.png")).toBeNull(); + expect(yield* resolveAsset(token, `../${name}`)).toBeNull(); + expect(yield* resolveAsset(`${token}tampered`, name)).toBeNull(); + } + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("resolves relative media paths from the thread workspace, including outside it", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-relative-" }); + const root = path.join(directory, "workspace"); + yield* fs.makeDirectory(root); + for (const relativePath of ["screenshot.png", "../recording.mp4"]) { + const filePath = path.resolve(root, relativePath); + yield* fs.writeFileString(filePath, "media"); + const result = yield* issueAssetUrl({ + resource: { _tag: "media-file", threadId: ThreadId.make("thread-1"), path: relativePath }, + workspaceRoot: root, + }); + const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const separator = suffix.indexOf("/"); + expect( + yield* resolveAsset(suffix.slice(0, separator), suffix.slice(separator + 1)), + ).toMatchObject({ + kind: "file", + path: yield* fs.realPath(filePath), + }); + } + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("rejects non-media files, disguised targets, and directories", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-validation-" }); + for (const name of ["report.html", "secret.txt", "secret.%70ng", "secret.png#private.txt"]) { + const filePath = path.join(root, name); + yield* fs.writeFileString(filePath, "not media"); + const error = yield* issueAssetUrl({ + resource: { _tag: "media-file", threadId: ThreadId.make("thread-1"), path: filePath }, + }).pipe(Effect.flip); + expect(error).toBeInstanceOf(AssetPreviewTypeValidationError); + } + const disguisedPath = path.join(root, "disguised.png"); + yield* fs.symlink(path.join(root, "report.html"), disguisedPath); + const disguisedError = yield* issueAssetUrl({ + resource: { _tag: "media-file", threadId: ThreadId.make("thread-1"), path: disguisedPath }, + }).pipe(Effect.flip); + expect(disguisedError).toBeInstanceOf(AssetPreviewTypeValidationError); + const directoryPath = path.join(root, "directory.png"); + yield* fs.makeDirectory(directoryPath); + const directoryError = yield* issueAssetUrl({ + resource: { _tag: "media-file", threadId: ThreadId.make("thread-1"), path: directoryPath }, + }).pipe(Effect.flip); + expect(directoryError._tag).toBe("AssetWorkspaceAssetNotFoundError"); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("binds media URLs to the canonical target and rejects symlink substitution", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-symlink-" }); + const filePath = path.join(root, "actual.svg"); + const aliasPath = path.join(root, "alias.png"); + const replacementPath = path.join(root, "other.svg"); + yield* fs.writeFileString(filePath, ""); + yield* fs.writeFileString(replacementPath, "private"); + yield* fs.symlink(filePath, aliasPath); + const canonicalFile = yield* fs.realPath(filePath); + const result = yield* issueAssetUrl({ + resource: { _tag: "media-file", threadId: ThreadId.make("thread-1"), path: aliasPath }, + }); + const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const separator = suffix.indexOf("/"); + const token = suffix.slice(0, separator); + const name = suffix.slice(separator + 1); + const expected = { kind: "file", path: canonicalFile, mimeType: "image/svg+xml" }; + expect(yield* resolveAsset(token, name)).toMatchObject(expected); + yield* fs.remove(aliasPath); + yield* fs.symlink(replacementPath, aliasPath); + expect(yield* resolveAsset(token, name)).toMatchObject(expected); + yield* fs.remove(filePath); + yield* fs.symlink(replacementPath, filePath); + expect(yield* resolveAsset(token, name)).toBeNull(); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("keeps full and partial responses bound to the file opened during resolution", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-open-file-" }); + const filePath = path.join(root, "recording.mp4"); + const savedPath = path.join(root, "saved.mp4"); + const secretPath = path.join(root, "secret.txt"); + yield* fs.writeFileString(filePath, "0123456789"); + yield* fs.writeFileString(secretPath, "private information"); + const result = yield* issueAssetUrl({ + resource: { _tag: "media-file", threadId: ThreadId.make("thread-1"), path: filePath }, + }); + const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const separator = suffix.indexOf("/"); + for (const [range, expected, status] of [ + [undefined, "0123456789", 200], + ["bytes=2-5", "2345", 206], + ] as const) { + const asset = yield* resolveAsset(suffix.slice(0, separator), suffix.slice(separator + 1)); + if (!asset) throw new Error("Expected a resolved media file"); + + yield* fs.rename(filePath, savedPath); + yield* fs.symlink(secretPath, filePath); + const response = HttpServerResponse.toWeb(yield* assetFileResponse(asset, range)); + expect(response.status).toBe(status); + expect(response.headers.get("content-length")).toBe(String(expected.length)); + expect(yield* Effect.promise(() => response.text())).toBe(expected); + yield* fs.remove(filePath); + yield* fs.rename(savedPath, filePath); + } + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("rejects a symlink swapped in after canonical validation but before open", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-open-race-" }); + const filePath = path.join(root, "recording.mp4"); + const secretPath = path.join(root, "secret.txt"); + yield* fs.writeFileString(filePath, "video"); + yield* fs.writeFileString(secretPath, "secret"); + const canonicalPath = yield* fs.realPath(filePath); + const result = yield* issueAssetUrl({ + resource: { _tag: "media-file", threadId: ThreadId.make("thread-1"), path: filePath }, + }); + const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const separator = suffix.indexOf("/"); + const swappingFileSystem = FileSystem.FileSystem.of({ + ...fs, + stat: Effect.fn(function* (requestedPath) { + const info = yield* fs.stat(requestedPath); + if (requestedPath === canonicalPath) { + yield* fs.remove(filePath); + yield* fs.symlink(secretPath, filePath); + } + return info; + }), + }); + expect( + yield* resolveAsset(suffix.slice(0, separator), suffix.slice(separator + 1)).pipe( + Effect.provideService(FileSystem.FileSystem, swappingFileSystem), + ), + ).toBeNull(); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("closes a descriptor rejected when its path changes during open", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-open-rejected-" }); + const filePath = path.join(root, "recording.mp4"); + const secretPath = path.join(root, "secret.txt"); + yield* fs.writeFileString(filePath, "video"); + yield* fs.writeFileString(secretPath, "secret"); + const canonicalPath = yield* fs.realPath(filePath); + const originalOpen = (yield* Effect.promise(() => + vi.importActual("node:fs/promises"), + )).open; + let opened: NodeFSP.FileHandle | undefined; + const openSpy = vi.mocked(NodeFSP.open).mockImplementation(async (target, flags, mode) => { + const handle = await originalOpen(target, flags, mode); + if (target === canonicalPath) { + opened = handle; + await NodeFSP.unlink(filePath); + await NodeFSP.symlink(secretPath, filePath); + } + return handle; + }); + yield* Effect.addFinalizer(() => Effect.sync(() => openSpy.mockImplementation(originalOpen))); + expect(yield* openMediaFile(canonicalPath)).toBeNull(); + expect(opened).toBeDefined(); + expect(opened?.fd).toBe(-1); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("rejects an ancestor symlink race even when canonical path rechecks would pass", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-parent-race-" }); + const publicDirectory = path.join(root, "public"); + const privateDirectory = path.join(root, "private"); + yield* fs.makeDirectory(publicDirectory); + yield* fs.makeDirectory(privateDirectory); + const filePath = path.join(publicDirectory, "recording.mp4"); + yield* fs.writeFileString(filePath, "public video"); + yield* fs.writeFileString(path.join(privateDirectory, "recording.mp4"), "private video"); + const canonicalPath = yield* fs.realPath(filePath); + const result = yield* issueAssetUrl({ + resource: { _tag: "media-file", threadId: ThreadId.make("thread-1"), path: filePath }, + }); + const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const separator = suffix.indexOf("/"); + const native = yield* Effect.promise(() => + vi.importActual("node:fs/promises"), + ); + const savedDirectory = path.join(root, "saved"); + const realpathSpy = vi.mocked(NodeFSP.realpath).mockImplementationOnce(async () => { + // A pathname-only guard can see the original parents during realpath, + // but the private file during both lstat calls and open. + await native.unlink(publicDirectory); + await native.rename(savedDirectory, publicDirectory); + const canonical = await native.realpath(canonicalPath); + await native.rename(publicDirectory, savedDirectory); + await native.symlink(privateDirectory, publicDirectory, "junction"); + return canonical; + }); + yield* Effect.addFinalizer(() => + Effect.sync(() => realpathSpy.mockReset().mockImplementation(native.realpath)), + ); + const swappingFileSystem = FileSystem.FileSystem.of({ + ...fs, + realPath: Effect.fn(function* (requestedPath) { + const canonical = yield* fs.realPath(requestedPath); + if (requestedPath === canonicalPath) { + yield* fs.rename(publicDirectory, savedDirectory); + yield* Effect.promise(() => + NodeFSP.symlink(privateDirectory, publicDirectory, "junction"), + ); + } + return canonical; + }), + }); + expect( + yield* resolveAsset(suffix.slice(0, separator), suffix.slice(separator + 1)).pipe( + Effect.provideService(FileSystem.FileSystem, swappingFileSystem), + ), + ).toBeNull(); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("keeps in-place edits readable but requires a new URL after atomic replacement", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-replacement-" }); + const filePath = path.join(root, "recording.mp4"); + yield* fs.writeFileString(filePath, "original"); + const input = { + resource: { + _tag: "media-file" as const, + threadId: ThreadId.make("thread-1"), + path: filePath, + }, + }; + const original = yield* issueAssetUrl(input); + const suffix = original.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const separator = suffix.indexOf("/"); + const token = suffix.slice(0, separator); + const name = suffix.slice(separator + 1); + yield* fs.writeFileString(filePath, "in-place edit"); + const edited = yield* resolveAsset(token, name); + if (!edited) throw new Error("Expected the edited media file"); + const editedResponse = HttpServerResponse.toWeb(yield* assetFileResponse(edited)); + expect(yield* Effect.promise(() => editedResponse.text())).toBe("in-place edit"); + + const replacement = path.join(root, "replacement.mp4"); + yield* fs.writeFileString(replacement, "replacement"); + yield* fs.rename(replacement, filePath); + expect(yield* resolveAsset(token, name)).toBeNull(); + + const renewed = yield* issueAssetUrl(input); + const renewedSuffix = renewed.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const renewedSeparator = renewedSuffix.indexOf("/"); + const renewedAsset = yield* resolveAsset( + renewedSuffix.slice(0, renewedSeparator), + renewedSuffix.slice(renewedSeparator + 1), + ); + if (!renewedAsset) throw new Error("Expected the replacement media file"); + const renewedResponse = HttpServerResponse.toWeb(yield* assetFileResponse(renewedAsset)); + expect(yield* Effect.promise(() => renewedResponse.text())).toBe("replacement"); + yield* fs.remove(filePath); + expect( + yield* resolveAsset( + renewedSuffix.slice(0, renewedSeparator), + renewedSuffix.slice(renewedSeparator + 1), + ), + ).toBeNull(); + }).pipe(Effect.provide(testLayer)), + ); + it.effect("issues workspace URLs that resolve the entry file and sibling assets", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -208,6 +543,37 @@ describe("AssetAccess", () => { }).pipe(Effect.provide(testLayer)), ); + it.effect("serves video attachments inline", () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const attachmentId = "thread-1-00000000-0000-4000-8000-000000000001-mp4"; + const attachmentPath = path.join(config.attachmentsDir, `${attachmentId}.mp4`); + yield* fileSystem.makeDirectory(config.attachmentsDir, { recursive: true }); + yield* fileSystem.writeFile(attachmentPath, new Uint8Array([1, 2, 3])); + + const result = yield* issueAssetUrl({ + resource: { + _tag: "attachment", + attachmentId, + fileName: "demo.mp4", + mimeType: 'video/mp4; codecs="avc1.42E01E"', + }, + }); + const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const separatorIndex = suffix.indexOf("/"); + + expect( + yield* resolveAsset(suffix.slice(0, separatorIndex), suffix.slice(separatorIndex + 1)), + ).toEqual({ + kind: "file", + path: attachmentPath, + fileName: "demo.mp4", + mimeType: "video/mp4", + }); + }).pipe(Effect.provide(testLayer)), + ); it.effect("issues project favicon capabilities with a signed fallback", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts index 232a41e5a9c8..05801acca88f 100644 --- a/apps/server/src/assets/AssetAccess.ts +++ b/apps/server/src/assets/AssetAccess.ts @@ -16,6 +16,7 @@ import { import { isWorkspaceImagePreviewPath, isWorkspacePreviewEntryPath, + mediaMimeTypeFromExtension, WORKSPACE_BROWSER_PREVIEW_EXTENSIONS, WORKSPACE_IMAGE_PREVIEW_EXTENSIONS, } from "@t3tools/shared/filePreview"; @@ -37,10 +38,11 @@ import { timingSafeEqualBase64Url, } from "../auth/utils.ts"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; -import { resolveAttachmentPathById } from "../attachmentStore.ts"; +import { parseAttachmentFileExtension, resolveAttachmentPathById } from "../attachmentStore.ts"; import * as ServerConfig from "../config.ts"; import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts"; import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; +import { openMediaFile, type OpenMediaFile } from "./MediaFile.ts"; export const ASSET_ROUTE_PREFIX = "/api/assets"; @@ -48,6 +50,7 @@ const SIGNING_SECRET_NAME = "asset-access-signing-key"; const ASSET_TOKEN_TTL_MS = 60 * 60 * 1000; const PROJECT_FAVICON_TOKEN_BUCKET_MS = 30 * 60 * 1000; const PROJECT_FAVICON_VERSION_PREFIX = "v"; +const INLINE_VIDEO_MIME_TYPE_PATTERN = /^video\/[\w!#$&^.+-]+$/i; const PREVIEW_ASSET_EXTENSIONS = new Set([ ...WORKSPACE_BROWSER_PREVIEW_EXTENSIONS, ...WORKSPACE_IMAGE_PREVIEW_EXTENSIONS, @@ -75,10 +78,25 @@ const AssetClaimsSchema = Schema.Union([ relativePath: Schema.String, expiresAt: Schema.Number, }), + Schema.Struct({ + version: Schema.Literal(1), + kind: Schema.Literal("media-file-exact"), + filePath: Schema.String, + device: Schema.String, + inode: Schema.String, + expiresAt: Schema.Number, + }), Schema.Struct({ version: Schema.Literal(1), kind: Schema.Literal("attachment"), attachmentId: Schema.String, + /** Decided at mint time. Absent tokens (from before this field) serve + inline, which is only ever the image case. */ + download: Schema.optionalKey(Schema.Boolean), + /** Display name and mime the caller supplied at mint time; drive the + download filename and Content-Type. */ + fileName: Schema.optionalKey(Schema.String), + mimeType: Schema.optionalKey(Schema.String), expiresAt: Schema.Number, }), Schema.Struct({ @@ -101,7 +119,14 @@ const AssetClaimsJson = Schema.fromJsonString(AssetClaimsSchema); const decodeAssetClaims = Schema.decodeUnknownOption(AssetClaimsJson); const encodeAssetClaims = Schema.encodeSync(AssetClaimsJson); -export type ResolvedAsset = { readonly kind: "file"; readonly path: string }; +export type ResolvedAsset = { + readonly kind: "file"; + readonly path: string; + readonly download?: boolean; + readonly fileName?: string; + readonly mimeType?: string; + readonly file?: OpenMediaFile; +}; function decodeClaims(encodedPayload: string): AssetClaims | null { try { @@ -197,6 +222,55 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i let sourcePath: string | undefined; switch (input.resource._tag) { + case "media-file": { + let requestedPath = input.resource.path; + if (!path.isAbsolute(requestedPath)) { + if (!input.workspaceRoot) { + return yield* new AssetWorkspaceContextNotFoundError({ resource: input.resource }); + } + const workspaceRoot = yield* workspacePaths + .normalizeWorkspaceRoot(input.workspaceRoot) + .pipe( + Effect.mapError( + (cause) => + new AssetWorkspaceRootNormalizationError({ resource: input.resource, cause }), + ), + ); + requestedPath = path.resolve(workspaceRoot, requestedPath); + } + const canonicalFile = yield* resolveCanonicalFile(requestedPath).pipe( + Effect.mapError( + (cause) => new AssetWorkspaceAssetInspectionError({ resource: input.resource, cause }), + ), + ); + if (!canonicalFile) { + return yield* new AssetWorkspaceAssetNotFoundError({ resource: input.resource }); + } + if (mediaMimeTypeFromExtension(path.extname(canonicalFile)) === null) { + return yield* new AssetPreviewTypeValidationError({ resource: input.resource }); + } + const identity = yield* openMediaFile(canonicalFile).pipe( + Effect.map((file) => + file ? { device: file.info.dev.toString(), inode: file.info.ino.toString() } : null, + ), + Effect.scoped, + Effect.mapError( + (cause) => new AssetWorkspaceAssetInspectionError({ resource: input.resource, cause }), + ), + ); + if (!identity) { + return yield* new AssetWorkspaceAssetNotFoundError({ resource: input.resource }); + } + claims = { + version: 1, + kind: "media-file-exact", + filePath: canonicalFile, + ...identity, + expiresAt, + }; + fileName = path.basename(canonicalFile); + break; + } case "workspace-file": { if (!input.workspaceRoot) { return yield* new AssetWorkspaceContextNotFoundError({ @@ -286,13 +360,24 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i resource: input.resource, }); } + // Generic files carry their extension inside the attachment id (that + // shape resolves the on-disk path); images do not. Videos and images + // render inline; other generic files download. + const isGenericFile = parseAttachmentFileExtension(input.resource.attachmentId) !== null; + const videoMimeType = input.resource.mimeType?.split(";", 1)[0]?.trim() ?? ""; + const isVideo = INLINE_VIDEO_MIME_TYPE_PATTERN.test(videoMimeType); claims = { version: 1, kind: "attachment", attachmentId: input.resource.attachmentId, + ...(isGenericFile && !isVideo ? { download: true } : {}), + ...(input.resource.fileName !== undefined ? { fileName: input.resource.fileName } : {}), + ...(input.resource.mimeType !== undefined + ? { mimeType: isVideo ? videoMimeType : input.resource.mimeType } + : {}), expiresAt, }; - fileName = path.basename(attachmentPath); + fileName = input.resource.fileName ?? path.basename(attachmentPath); break; } case "project-favicon": { @@ -464,7 +549,13 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( Effect.orElseSucceed(() => Option.none()), ); return Option.isSome(info) && info.value.type === "File" - ? ({ kind: "file", path: attachmentPath } satisfies ResolvedAsset) + ? ({ + kind: "file", + path: attachmentPath, + ...(claims.download ? { download: true } : {}), + ...(claims.fileName !== undefined ? { fileName: claims.fileName } : {}), + ...(claims.mimeType !== undefined ? { mimeType: claims.mimeType } : {}), + } satisfies ResolvedAsset) : null; } @@ -495,6 +586,30 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( const decodedPath = decodeRelativePath(relativePath); if (decodedPath === null) return null; const path = yield* Path.Path; + if (claims.kind === "media-file-exact") { + if (decodedPath !== path.basename(claims.filePath)) return null; + const canonicalFile = yield* resolveCanonicalFile(claims.filePath).pipe( + Effect.tapError((cause) => + Effect.logError("Failed to resolve canonical media path.", { + filePath: claims.filePath, + cause, + }), + ), + Effect.orElseSucceed(() => null), + ); + if (canonicalFile !== claims.filePath) return null; + const mimeType = mediaMimeTypeFromExtension(path.extname(canonicalFile)); + if (!mimeType) return null; + const file = yield* openMediaFile(canonicalFile, claims).pipe( + Effect.tapError((cause) => + Effect.logError("Failed to open canonical media file.", { filePath: canonicalFile, cause }), + ), + Effect.orElseSucceed(() => null), + ); + return file + ? ({ kind: "file", path: canonicalFile, mimeType, file } satisfies ResolvedAsset) + : null; + } if (claims.kind === "workspace-file-exact") { if (decodedPath !== path.basename(claims.relativePath)) return null; const exactWorkspaceFile = yield* resolveCanonicalWorkspaceFileForRequest({ diff --git a/apps/server/src/assets/AttachmentUpload.test.ts b/apps/server/src/assets/AttachmentUpload.test.ts index cb08d5e4b2f1..6fffa1d1f9f2 100644 --- a/apps/server/src/assets/AttachmentUpload.test.ts +++ b/apps/server/src/assets/AttachmentUpload.test.ts @@ -4,11 +4,16 @@ import * as NodePath from "node:path"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { describe, expect, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import { base64UrlEncode, signPayload } from "../auth/utils.ts"; import * as ServerConfig from "../config.ts"; import { parseThreadSegmentFromAttachmentId } from "../attachmentStore.ts"; import { @@ -30,6 +35,19 @@ const uploadInput = { sizeBytes: 6, } as const; +const LegacyAttachmentUploadClaims = Schema.Struct({ + version: Schema.Literal(1), + kind: Schema.Literal("attachment-upload"), + attachmentId: Schema.String, + name: Schema.String, + mimeType: Schema.String, + sizeBytes: Schema.Number, + expiresAt: Schema.Number, +}); +const encodeLegacyAttachmentUploadClaims = Schema.encodeEffect( + Schema.fromJsonString(LegacyAttachmentUploadClaims), +); + describe("AttachmentUpload", () => { it.effect("signs the attachment metadata and validates the upload token", () => Effect.gen(function* () { @@ -59,6 +77,31 @@ describe("AttachmentUpload", () => { }).pipe(Effect.provide(testLayer)), ); + it.effect("accepts unexpired image upload tokens issued before file support", () => + Effect.gen(function* () { + const issued = yield* issueAttachmentUploadUrl(uploadInput); + const secretStore = yield* ServerSecretStore.ServerSecretStore; + const secret = yield* secretStore.getOrCreateRandom("asset-access-signing-key", 32); + const encodedPayload = base64UrlEncode( + yield* encodeLegacyAttachmentUploadClaims({ + version: 1, + kind: "attachment-upload", + attachmentId: issued.attachmentId, + name: uploadInput.name, + mimeType: uploadInput.mimeType, + sizeBytes: uploadInput.sizeBytes, + expiresAt: issued.expiresAt, + }), + ); + const legacyToken = `${encodedPayload}.${signPayload(encodedPayload, secret)}`; + + expect(yield* validateAttachmentUploadToken(legacyToken)).toMatchObject({ + type: "image", + attachmentId: issued.attachmentId, + }); + }).pipe(Effect.provide(testLayer)), + ); + it.effect("rejects expired upload tokens", () => Effect.gen(function* () { const issued = yield* issueAttachmentUploadUrl(uploadInput); @@ -108,6 +151,85 @@ describe("AttachmentUpload", () => { }).pipe(Effect.provide(testLayer)), ); + it.effect("streams generic files to a path with their original extension", () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const issued = yield* issueAttachmentUploadUrl({ + type: "file", + name: "report.PDF", + mimeType: "application/pdf", + sizeBytes: 6, + }); + const token = issued.relativeUrl.slice(`${ATTACHMENT_UPLOAD_ROUTE_PREFIX}/`.length); + const claims = yield* validateAttachmentUploadToken(token); + if (!claims) { + throw new Error("Expected valid upload claims."); + } + + expect( + yield* storeAttachmentUpload( + claims, + Stream.make(new Uint8Array([1, 2, 3]), new Uint8Array([4, 5, 6])), + ), + ).toEqual({ ok: true }); + expect(issued.attachmentId).toMatch(/-pdf$/); + expect( + NodeFS.readFileSync(NodePath.join(config.attachmentsDir, `${issued.attachmentId}.pdf`)), + ).toEqual(Buffer.from([1, 2, 3, 4, 5, 6])); + + yield* deletePendingAttachment(issued.attachmentId); + expect(NodeFS.readdirSync(config.attachmentsDir)).toEqual([]); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("removes partial streamed uploads that exceed their signed size", () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const issued = yield* issueAttachmentUploadUrl(uploadInput); + const token = issued.relativeUrl.slice(`${ATTACHMENT_UPLOAD_ROUTE_PREFIX}/`.length); + const claims = yield* validateAttachmentUploadToken(token); + if (!claims) { + throw new Error("Expected valid upload claims."); + } + + expect(yield* storeAttachmentUpload(claims, Stream.make(new Uint8Array(7)))).toMatchObject({ + ok: false, + status: 400, + }); + expect(NodeFS.readdirSync(config.attachmentsDir)).toEqual([]); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("removes partial streamed uploads when the upload is interrupted", () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const issued = yield* issueAttachmentUploadUrl(uploadInput); + const token = issued.relativeUrl.slice(`${ATTACHMENT_UPLOAD_ROUTE_PREFIX}/`.length); + const claims = yield* validateAttachmentUploadToken(token); + if (!claims) { + throw new Error("Expected valid upload claims."); + } + + const nextChunkRequested = yield* Deferred.make(); + const body = Stream.make(new Uint8Array([1, 2, 3])).pipe( + Stream.concat( + Stream.fromEffect( + Deferred.succeed(nextChunkRequested, undefined).pipe(Effect.andThen(Effect.never)), + ), + ), + ); + const upload = yield* storeAttachmentUpload(claims, body).pipe(Effect.forkScoped); + + yield* Deferred.await(nextChunkRequested); + expect( + NodeFS.readdirSync(config.attachmentsDir).filter((entry) => entry.endsWith(".part")), + ).toHaveLength(1); + + yield* Fiber.interrupt(upload); + expect(NodeFS.readdirSync(config.attachmentsDir)).toEqual([]); + }).pipe(Effect.provide(testLayer)), + ); + it.effect("deletes pending uploads without deleting thread-owned copies", () => Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; diff --git a/apps/server/src/assets/AttachmentUpload.ts b/apps/server/src/assets/AttachmentUpload.ts index 6142b69d7342..ba3539a3df40 100644 --- a/apps/server/src/assets/AttachmentUpload.ts +++ b/apps/server/src/assets/AttachmentUpload.ts @@ -12,8 +12,11 @@ import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import type * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; import { + attachmentFileExtension, createPendingAttachmentId, parseThreadSegmentFromAttachmentId, PENDING_ATTACHMENT_THREAD_SEGMENT, @@ -41,6 +44,9 @@ const lastPendingSweepByDirectory = new Map(); const AttachmentUploadClaims = Schema.Struct({ version: Schema.Literal(1), kind: Schema.Literal("attachment-upload"), + type: Schema.Literals(["image", "file"]).pipe( + Schema.withDecodingDefault(Effect.succeed("image" as const)), + ), attachmentId: Schema.String, name: Schema.String, mimeType: Schema.String, @@ -89,12 +95,16 @@ export const issueAttachmentUploadUrl = Effect.fn("AttachmentUpload.issueUrl")(f } } - const attachmentId = createPendingAttachmentId(); + const attachmentType = input.type ?? "image"; + const attachmentId = createPendingAttachmentId( + attachmentType === "file" ? attachmentFileExtension(input.name) : undefined, + ); const expiresAt = nowMs + ATTACHMENT_UPLOAD_URL_TTL_MS; const encodedPayload = base64UrlEncode( encodeAttachmentUploadClaims({ version: 1, kind: "attachment-upload", + type: attachmentType, attachmentId, name: input.name, mimeType: input.mimeType, @@ -141,18 +151,21 @@ export type StoreAttachmentUploadResult = export const storeAttachmentUpload = Effect.fn("AttachmentUpload.store")(function* ( claims: AttachmentUploadClaims, - bytes: Uint8Array, + body: Uint8Array | HttpServerRequest.HttpServerRequest["stream"], ) { - if (bytes.byteLength !== claims.sizeBytes) { + if (body instanceof Uint8Array && body.byteLength !== claims.sizeBytes) { return { ok: false, status: 400, - detail: `Body was ${bytes.byteLength} bytes, expected ${claims.sizeBytes}.`, + detail: `Body was ${body.byteLength} bytes, expected ${claims.sizeBytes}.`, } satisfies StoreAttachmentUploadResult; } const config = yield* ServerConfig.ServerConfig; - const extension = inferImageExtension({ mimeType: claims.mimeType, fileName: claims.name }); + const extension = + claims.type === "file" + ? attachmentFileExtension(claims.name) + : inferImageExtension({ mimeType: claims.mimeType, fileName: claims.name }); const relativePath = `${claims.attachmentId}${extension}`; const finalPath = resolveAttachmentRelativePath({ attachmentsDir: config.attachmentsDir, @@ -168,21 +181,34 @@ export const storeAttachmentUpload = Effect.fn("AttachmentUpload.store")(functio const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; + let receivedBytes = 0; + const bodyStream = body instanceof Uint8Array ? Stream.make(body) : body; return yield* Effect.gen(function* () { yield* fileSystem.makeDirectory(path.dirname(finalPath), { recursive: true }); - yield* fileSystem.writeFile(partPath, bytes); + yield* Stream.run( + bodyStream.pipe( + Stream.takeWhile((chunk) => { + receivedBytes += chunk.byteLength; + return receivedBytes <= claims.sizeBytes; + }), + ), + fileSystem.sink(partPath), + ); + if (receivedBytes !== claims.sizeBytes) { + return { + ok: false, + status: 400, + detail: `Body was ${receivedBytes} bytes, expected ${claims.sizeBytes}.`, + } satisfies StoreAttachmentUploadResult; + } yield* fileSystem.rename(partPath, finalPath); return { ok: true } satisfies StoreAttachmentUploadResult; }).pipe( Effect.catch((cause) => - fileSystem.remove(partPath, { force: true }).pipe( - Effect.orElseSucceed(() => undefined), - Effect.andThen( - Effect.logError("Failed to persist attachment upload.", { - attachmentId: claims.attachmentId, - cause, - }), - ), + Effect.logError("Failed to persist attachment upload.", { + attachmentId: claims.attachmentId, + cause, + }).pipe( Effect.as({ ok: false, status: 500, @@ -190,6 +216,9 @@ export const storeAttachmentUpload = Effect.fn("AttachmentUpload.store")(functio } satisfies StoreAttachmentUploadResult), ), ), + Effect.ensuring( + fileSystem.remove(partPath, { force: true }).pipe(Effect.orElseSucceed(() => undefined)), + ), ); }); diff --git a/apps/server/src/assets/MediaFile.ts b/apps/server/src/assets/MediaFile.ts new file mode 100644 index 000000000000..f1b63bb659e3 --- /dev/null +++ b/apps/server/src/assets/MediaFile.ts @@ -0,0 +1,113 @@ +// @effect-diagnostics nodeBuiltinImport:off - FileSystem does not expose no-follow +// or non-blocking open flags, and the response must keep the validated descriptor. +import * as NodeFS from "node:fs"; +import * as NodeFSP from "node:fs/promises"; + +import * as NodeStream from "@effect/platform-node/NodeStream"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +class MediaFileOpenError extends Schema.TaggedErrorClass()( + "MediaFileOpenError", + { + path: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to open media file '${this.path}'.`; + } +} + +class MediaFileStatError extends Schema.TaggedErrorClass()( + "MediaFileStatError", + { + path: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to read metadata for media file '${this.path}'.`; + } +} + +/** Holds the file identity and descriptor for one HTTP request, never a copy of its bytes. */ +export interface OpenMediaFile { + readonly handle: NodeFSP.FileHandle; + readonly info: NodeFS.BigIntStats; +} + +/** Opens a canonical media path once. Replacements cannot change the response's source. */ +export const openMediaFile = Effect.fn("openMediaFile")(function* ( + filePath: string, + identity?: { readonly device: string; readonly inode: string }, +) { + return yield* Effect.acquireRelease( + Effect.tryPromise({ + try: async () => { + const before = await NodeFSP.lstat(filePath, { bigint: true }); + if (!before.isFile() || before.ino === 0n) return null; + if ( + identity && + (before.dev.toString() !== identity.device || before.ino.toString() !== identity.inode) + ) { + return null; + } + + // Windows lacks these flags; the descriptor/path identity checks still apply. + const handle = await NodeFSP.open( + filePath, + NodeFS.constants.O_RDONLY | + (NodeFS.constants.O_NOFOLLOW ?? 0) | + (NodeFS.constants.O_NONBLOCK ?? 0), + ); + let accepted = false; + try { + const info = await handle.stat({ bigint: true }); + if (!info.isFile() || info.dev !== before.dev || info.ino !== before.ino) return null; + if ( + identity && + (info.dev.toString() !== identity.device || info.ino.toString() !== identity.inode) + ) { + return null; + } + if ((await NodeFSP.realpath(filePath)) !== filePath) return null; + const after = await NodeFSP.lstat(filePath, { bigint: true }); + if (!after.isFile() || info.dev !== after.dev || info.ino !== after.ino) return null; + accepted = true; + return { handle, info } satisfies OpenMediaFile; + } finally { + if (!accepted) await handle.close(); + } + }, + catch: (cause) => new MediaFileOpenError({ path: filePath, cause }), + }), + (file) => (file ? Effect.promise(() => file.handle.close()) : Effect.void), + ); +}); + +export const statMediaFile = Effect.fn("statMediaFile")(function* ( + filePath: string, + file: OpenMediaFile, +) { + return yield* Effect.tryPromise({ + try: () => file.handle.stat({ bigint: true }), + catch: (cause) => new MediaFileStatError({ path: filePath, cause }), + }); +}); + +export const streamMediaFile = (file: OpenMediaFile, offset: bigint, bytesToRead: bigint) => { + const start = Number(offset); + const end = Number(offset + bytesToRead - 1n); + if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 0 || end < start) { + return null; + } + return NodeStream.fromReadable({ + evaluate: () => + file.handle.createReadStream({ + autoClose: false, + start, + end, + }), + }); +}; diff --git a/apps/server/src/attachmentStore.test.ts b/apps/server/src/attachmentStore.test.ts index 5e782e55407f..59374f18ba48 100644 --- a/apps/server/src/attachmentStore.test.ts +++ b/apps/server/src/attachmentStore.test.ts @@ -6,9 +6,11 @@ import * as NodePath from "node:path"; import { describe, expect, it } from "vite-plus/test"; import { + attachmentFileExtension, createAttachmentId, createPendingAttachmentId, parseAttachmentUuid, + parseAttachmentFileExtension, planAttachmentClaim, parseThreadSegmentFromAttachmentId, resolveAttachmentPathById, @@ -58,6 +60,21 @@ describe("attachmentStore", () => { ); }); + it("preserves safe file extensions in attachment ids and paths", () => { + const attachmentId = createPendingAttachmentId(".PDF"); + + expect(parseThreadSegmentFromAttachmentId(attachmentId)).toBe("pending"); + expect(parseAttachmentUuid(attachmentId)).toMatch(/^[a-f0-9-]{36}$/); + expect(parseAttachmentFileExtension(attachmentId)).toBe("pdf"); + expect(attachmentFileExtension("report.PDF")).toBe(".pdf"); + expect(attachmentFileExtension("report")).toBe(".bin"); + expect(attachmentFileExtension("report.extensiontoolong")).toBe(".bin"); + // ".part" is the in-flight upload suffix; storing it would make the file + // look like a stale partial to the sweep. + expect(attachmentFileExtension("archive.part")).toBe(".bin"); + expect(createAttachmentId("x".repeat(80), ".abcdefghij")?.length).toBeLessThanOrEqual(128); + }); + it("resolves attachment path by id using the extension that exists on disk", () => { const attachmentsDir = NodeFS.mkdtempSync( NodePath.join(NodeOS.tmpdir(), "t3code-attachment-store-"), @@ -92,6 +109,21 @@ describe("attachmentStore", () => { } }); + it("resolves generic attachments without scanning the attachment directory", () => { + const attachmentsDir = NodeFS.mkdtempSync( + NodePath.join(NodeOS.tmpdir(), "t3code-file-attachment-"), + ); + try { + const attachmentId = "thread-1-00000000-0000-4000-8000-000000000001-zip"; + const archivePath = NodePath.join(attachmentsDir, `${attachmentId}.zip`); + NodeFS.writeFileSync(archivePath, Buffer.from("archive")); + + expect(resolveAttachmentPathById({ attachmentsDir, attachmentId })).toBe(archivePath); + } finally { + NodeFS.rmSync(attachmentsDir, { recursive: true, force: true }); + } + }); + it("plans pending attachment claims with direct filename lookups", () => { const attachmentsDir = NodeFS.mkdtempSync( NodePath.join(NodeOS.tmpdir(), "t3code-attachment-claim-"), @@ -147,15 +179,17 @@ describe("attachmentStore", () => { const oldTimeSeconds = (now - 2 * 24 * 60 * 60 * 1000) / 1000; const uuid = "00000000-0000-4000-8000-000000000002"; const pendingPath = NodePath.join(attachmentsDir, `pending-${uuid}.png`); + const pendingFilePath = NodePath.join(attachmentsDir, `pending-${uuid}-pdf.pdf`); const threadPath = NodePath.join(attachmentsDir, `thread-1-${uuid}.png`); const partialPath = NodePath.join(attachmentsDir, `${uuid}.part`); - for (const filePath of [pendingPath, threadPath, partialPath]) { + for (const filePath of [pendingPath, pendingFilePath, threadPath, partialPath]) { NodeFS.writeFileSync(filePath, Buffer.from("pixels")); NodeFS.utimesSync(filePath, oldTimeSeconds, oldTimeSeconds); } - expect(sweepStalePendingAttachments({ attachmentsDir, nowMs: now })).toEqual({ deleted: 2 }); + expect(sweepStalePendingAttachments({ attachmentsDir, nowMs: now })).toEqual({ deleted: 3 }); expect(NodeFS.existsSync(pendingPath)).toBe(false); + expect(NodeFS.existsSync(pendingFilePath)).toBe(false); expect(NodeFS.existsSync(partialPath)).toBe(false); expect(NodeFS.existsSync(threadPath)).toBe(true); } finally { diff --git a/apps/server/src/attachmentStore.ts b/apps/server/src/attachmentStore.ts index d0334bce09f3..261b094645b9 100644 --- a/apps/server/src/attachmentStore.ts +++ b/apps/server/src/attachmentStore.ts @@ -15,8 +15,9 @@ const ATTACHMENT_FILENAME_EXTENSIONS = [...SAFE_IMAGE_FILE_EXTENSIONS, ".bin"]; const ATTACHMENT_ID_THREAD_SEGMENT_MAX_CHARS = 80; const ATTACHMENT_ID_THREAD_SEGMENT_PATTERN = "[a-z0-9_]+(?:-[a-z0-9_]+)*"; const ATTACHMENT_ID_UUID_PATTERN = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"; +const ATTACHMENT_ID_FILE_EXTENSION_PATTERN = "[a-z0-9]{1,10}"; const ATTACHMENT_ID_PATTERN = new RegExp( - `^(${ATTACHMENT_ID_THREAD_SEGMENT_PATTERN})-(${ATTACHMENT_ID_UUID_PATTERN})$`, + `^(${ATTACHMENT_ID_THREAD_SEGMENT_PATTERN})-(${ATTACHMENT_ID_UUID_PATTERN})(?:-(${ATTACHMENT_ID_FILE_EXTENSION_PATTERN}))?$`, "i", ); @@ -39,8 +40,28 @@ export function toSafeThreadAttachmentSegment(threadId: string): string | null { return segment === PENDING_ATTACHMENT_THREAD_SEGMENT ? "_pending" : segment; } -export function createPendingAttachmentId(): string { - return `${PENDING_ATTACHMENT_THREAD_SEGMENT}-${NodeCrypto.randomUUID()}`; +export function attachmentFileExtension(fileName: string): string { + const extension = NodePath.extname(fileName).toLowerCase(); + // ".part" is reserved for in-flight uploads; a stored "archive.part" would + // look stale to sweepStalePendingAttachments and get deleted. + if (extension === ".part" || !/^\.[a-z0-9]{1,10}$/.test(extension)) { + return ".bin"; + } + return extension; +} + +function attachmentIdExtensionSuffix(extension: string | undefined): string { + if (!extension) { + return ""; + } + const normalized = extension.replace(/^\./, "").toLowerCase(); + return new RegExp(`^${ATTACHMENT_ID_FILE_EXTENSION_PATTERN}$`).test(normalized) + ? `-${normalized}` + : "-bin"; +} + +export function createPendingAttachmentId(extension?: string): string { + return `${PENDING_ATTACHMENT_THREAD_SEGMENT}-${NodeCrypto.randomUUID()}${attachmentIdExtensionSuffix(extension)}`; } export function parseAttachmentUuid(attachmentId: string): string | null { @@ -51,12 +72,20 @@ export function parseAttachmentUuid(attachmentId: string): string | null { return normalizedId.match(ATTACHMENT_ID_PATTERN)?.[2]?.toLowerCase() ?? null; } -export function createAttachmentId(threadId: string): string | null { +export function parseAttachmentFileExtension(attachmentId: string): string | null { + const normalizedId = normalizeAttachmentRelativePath(attachmentId); + if (!normalizedId || normalizedId.includes("/") || normalizedId.includes(".")) { + return null; + } + return normalizedId.match(ATTACHMENT_ID_PATTERN)?.[3]?.toLowerCase() ?? null; +} + +export function createAttachmentId(threadId: string, extension?: string): string | null { const threadSegment = toSafeThreadAttachmentSegment(threadId); if (!threadSegment) { return null; } - return `${threadSegment}-${NodeCrypto.randomUUID()}`; + return `${threadSegment}-${NodeCrypto.randomUUID()}${attachmentIdExtensionSuffix(extension)}`; } export function parseThreadSegmentFromAttachmentId(attachmentId: string): string | null { @@ -71,7 +100,8 @@ export function parseThreadSegmentFromAttachmentId(attachmentId: string): string return match[1]?.toLowerCase() ?? null; } -export function attachmentRelativePath(attachment: ChatAttachment): string { +/** Null for attachment types this build does not know; callers skip those. */ +export function attachmentRelativePath(attachment: ChatAttachment): string | null { switch (attachment.type) { case "image": { const extension = inferImageExtension({ @@ -80,6 +110,10 @@ export function attachmentRelativePath(attachment: ChatAttachment): string { }); return `${attachment.id}${extension}`; } + case "file": + return `${attachment.id}${attachmentFileExtension(attachment.name)}`; + default: + return null; } } @@ -87,9 +121,13 @@ export function resolveAttachmentPath(input: { readonly attachmentsDir: string; readonly attachment: ChatAttachment; }): string | null { + const relativePath = attachmentRelativePath(input.attachment); + if (!relativePath) { + return null; + } return resolveAttachmentRelativePath({ attachmentsDir: input.attachmentsDir, - relativePath: attachmentRelativePath(input.attachment), + relativePath, }); } @@ -101,6 +139,14 @@ export function resolveAttachmentPathById(input: { if (!normalizedId || normalizedId.includes("/") || normalizedId.includes(".")) { return null; } + const fileExtension = parseAttachmentFileExtension(normalizedId); + if (fileExtension) { + const filePath = resolveAttachmentRelativePath({ + attachmentsDir: input.attachmentsDir, + relativePath: `${normalizedId}.${fileExtension.toLowerCase()}`, + }); + return filePath && NodeFS.existsSync(filePath) ? filePath : null; + } for (const extension of ATTACHMENT_FILENAME_EXTENSIONS) { const maybePath = resolveAttachmentRelativePath({ attachmentsDir: input.attachmentsDir, @@ -147,7 +193,8 @@ export function planAttachmentClaim(input: { if (!currentPath) { return { ok: false, reason: "attachment not found (removed or expired)" }; } - const finalId = createAttachmentId(input.threadId); + const fileExtension = parseAttachmentFileExtension(input.attachmentId) ?? undefined; + const finalId = createAttachmentId(input.threadId, fileExtension); if (!finalId) { return { ok: false, reason: "failed to create attachment id" }; } diff --git a/apps/server/src/auth/EnvironmentAuth.test.ts b/apps/server/src/auth/EnvironmentAuth.test.ts index 440efcee51ee..6e5f22fa3af6 100644 --- a/apps/server/src/auth/EnvironmentAuth.test.ts +++ b/apps/server/src/auth/EnvironmentAuth.test.ts @@ -5,6 +5,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as ServerConfig from "../config.ts"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; import * as PairingGrantStore from "./PairingGrantStore.ts"; import * as EnvironmentAuth from "./EnvironmentAuth.ts"; @@ -34,6 +35,7 @@ const makeEnvironmentAuthLayer = (overrides?: Partial { }).pipe(Effect.provide(makeEnvironmentAuthLayer())), ); + it.effect("prefers a bearer token over a stale legacy cookie", () => + Effect.gen(function* () { + const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; + const sessions = yield* SessionStore.SessionStore; + const bearer = yield* serverAuth.issueSession(); + const verified = yield* serverAuth.authenticateHttpRequest({ + cookies: { [sessions.legacyCookieName ?? "t3_session"]: "stale" }, + headers: { authorization: `Bearer ${bearer.token}` }, + } as never); + + expect(verified.sessionId).toBe(bearer.sessionId); + }).pipe(Effect.provide(makeEnvironmentAuthLayer({ mode: "web", host: "192.168.1.50" }))), + ); + it.effect("does not exchange ordinary pairing grants for administrative access tokens", () => Effect.gen(function* () { const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; diff --git a/apps/server/src/auth/EnvironmentAuth.ts b/apps/server/src/auth/EnvironmentAuth.ts index eb0563421408..08838cb7b780 100644 --- a/apps/server/src/auth/EnvironmentAuth.ts +++ b/apps/server/src/auth/EnvironmentAuth.ts @@ -16,6 +16,8 @@ import { type ServerAuthDescriptor, type ServerAuthSessionMethod, type AuthWebSocketTicketResult, + DpopFailureReason, + type DpopFailureReason as DpopFailureReasonType, } from "@t3tools/contracts"; import { encodeOAuthScope } from "@t3tools/shared/oauthScope"; import * as Context from "effect/Context"; @@ -28,6 +30,7 @@ import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import * as EnvironmentAuthPolicy from "./EnvironmentAuthPolicy.ts"; import * as PairingGrantStore from "./PairingGrantStore.ts"; import * as ServerSecretStore from "./ServerSecretStore.ts"; @@ -347,6 +350,7 @@ export class ServerAuthInvalidCredentialError extends Schema.TaggedErrorClass error._tag === "ServerAuthMissingCredentialError" ? "missing_credential" : "invalid_credential"; +export const serverAuthDpopFailureReason = ( + error: ServerAuthCredentialError, +): DpopFailureReasonType | undefined => + error._tag === "ServerAuthInvalidCredentialError" ? error.dpopFailureReason : undefined; + export class ServerAuthInvalidScopeError extends Schema.TaggedErrorClass()( "ServerAuthInvalidScopeError", {}, @@ -554,6 +563,34 @@ function parseDpopToken(request: HttpServerRequest.HttpServerRequest): string | return token.length > 0 ? token : null; } +export function selectRequestCredential( + request: HttpServerRequest.HttpServerRequest, + cookieName: string, + legacyCookieName: string | undefined, +) { + const cookieToken = request.cookies[cookieName]; + if (cookieToken !== undefined) { + return { token: cookieToken, source: "cookie" } as const; + } + + const bearerToken = parseBearerToken(request); + if (bearerToken !== null) { + return { token: bearerToken, source: "bearer" } as const; + } + + const dpopToken = parseDpopToken(request); + if (dpopToken !== null) { + return { token: dpopToken, source: "dpop" } as const; + } + + const legacyToken = legacyCookieName ? request.cookies[legacyCookieName] : undefined; + if (legacyToken !== undefined) { + return { token: legacyToken, source: "legacy-cookie" } as const; + } + + return undefined; +} + export const make = Effect.gen(function* () { const policy = yield* EnvironmentAuthPolicy.EnvironmentAuthPolicy; const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore; @@ -592,20 +629,23 @@ export const make = Effect.gen(function* () { const authenticateRequest = ( request: HttpServerRequest.HttpServerRequest, ): Effect.Effect => { - const cookieToken = request.cookies[sessions.cookieName]; - const bearerToken = parseBearerToken(request); - const dpopToken = parseDpopToken(request); - const credential = cookieToken ?? bearerToken ?? dpopToken; - if (!credential) { + const credential = selectRequestCredential( + request, + sessions.cookieName, + sessions.legacyCookieName, + ); + if (!credential?.token) { return Effect.fail(new ServerAuthMissingCredentialError({})); } - return authenticateToken(credential).pipe( + const dpopToken = parseDpopToken(request); + return authenticateToken(credential.token).pipe( Effect.flatMap((session) => { if (session.proofKeyThumbprint) { - if (!dpopToken || dpopToken !== credential) { + if (!dpopToken || dpopToken !== credential.token) { return Effect.fail( new ServerAuthInvalidCredentialError({ diagnostic: "DPoP-bound access token requires DPoP authorization.", + dpopFailureReason: "invalid_proof", }), ); } @@ -623,6 +663,7 @@ export const make = Effect.gen(function* () { return Effect.fail( new ServerAuthInvalidCredentialError({ diagnostic: "DPoP authorization requires a proof-bound access token.", + dpopFailureReason: "invalid_proof", }), ); } @@ -993,4 +1034,7 @@ export const layer = Layer.effect(EnvironmentAuth, make).pipe( export const storageLayer = Layer.mergeAll(ServerSecretStore.layer, SqlitePersistenceLayer); -export const runtimeLayer = layer.pipe(Layer.provideMerge(storageLayer)); +export const runtimeLayer = layer.pipe( + Layer.provideMerge(storageLayer), + Layer.provideMerge(ServerEnvironment.identityLayer), +); diff --git a/apps/server/src/auth/EnvironmentAuthAdmin.test.ts b/apps/server/src/auth/EnvironmentAuthAdmin.test.ts index 03009270e15c..331a722534b4 100644 --- a/apps/server/src/auth/EnvironmentAuthAdmin.test.ts +++ b/apps/server/src/auth/EnvironmentAuthAdmin.test.ts @@ -4,6 +4,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as ServerConfig from "../config.ts"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; import * as EnvironmentAuth from "./EnvironmentAuth.ts"; import * as ServerSecretStore from "./ServerSecretStore.ts"; @@ -35,6 +36,7 @@ const makeEnvironmentAuthLayer = ( EnvironmentAuth.layer.pipe( Layer.provideMerge(ServerSecretStore.layer), Layer.provideMerge(SqlitePersistenceMemory), + Layer.provide(ServerEnvironment.identityLayer), Layer.provide(makeServerConfigLayer(overrides)), ); diff --git a/apps/server/src/auth/EnvironmentAuthPolicy.test.ts b/apps/server/src/auth/EnvironmentAuthPolicy.test.ts index 8e4c21710880..982ff397db40 100644 --- a/apps/server/src/auth/EnvironmentAuthPolicy.test.ts +++ b/apps/server/src/auth/EnvironmentAuthPolicy.test.ts @@ -4,12 +4,14 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as ServerConfig from "../config.ts"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import * as EnvironmentAuthPolicy from "./EnvironmentAuthPolicy.ts"; const makeEnvironmentAuthPolicyLayer = ( overrides?: Partial, ) => EnvironmentAuthPolicy.layer.pipe( + Layer.provide(ServerEnvironment.identityLayer), Layer.provide( Layer.effect( ServerConfig.ServerConfig, @@ -107,7 +109,7 @@ it.layer(NodeServices.layer)("EnvironmentAuthPolicy.layer", (it) => { expect(descriptor.policy).toBe("remote-reachable"); expect(descriptor.bootstrapMethods).toEqual(["one-time-token"]); - expect(descriptor.sessionCookieName).toBe("t3_session"); + expect(descriptor.sessionCookieName).toMatch(/^t3_session_[a-f0-9]{12}$/); }).pipe( Effect.provide( makeEnvironmentAuthPolicyLayer({ @@ -143,7 +145,7 @@ it.layer(NodeServices.layer)("EnvironmentAuthPolicy.layer", (it) => { const descriptor = yield* policy.getDescriptor(); expect(descriptor.policy).toBe("remote-reachable"); - expect(descriptor.sessionCookieName).toBe("t3_session"); + expect(descriptor.sessionCookieName).toMatch(/^t3_session_[a-f0-9]{12}$/); }).pipe( Effect.provide( makeEnvironmentAuthPolicyLayer({ diff --git a/apps/server/src/auth/EnvironmentAuthPolicy.ts b/apps/server/src/auth/EnvironmentAuthPolicy.ts index 9945c69067d7..446b8a8bba95 100644 --- a/apps/server/src/auth/EnvironmentAuthPolicy.ts +++ b/apps/server/src/auth/EnvironmentAuthPolicy.ts @@ -4,6 +4,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as ServerConfig from "../config.ts"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import { isRemoteReachableHost, resolveSessionCookieName } from "./utils.ts"; export class EnvironmentAuthPolicy extends Context.Service< @@ -15,6 +16,7 @@ export class EnvironmentAuthPolicy extends Context.Service< export const make = Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; + const serverEnvironment = yield* ServerEnvironment.ServerEnvironmentIdentity; const isRemoteReachable = isRemoteReachableHost(config.host); const policy = @@ -42,6 +44,7 @@ export const make = Effect.gen(function* () { port: config.port, host: config.host, instanceKey: config.stateDir, + environmentId: yield* serverEnvironment.getEnvironmentId, development: config.devUrl !== undefined, }), }; diff --git a/apps/server/src/auth/SessionStore.test.ts b/apps/server/src/auth/SessionStore.test.ts index 1fb01c1f0002..aa3b2d199148 100644 --- a/apps/server/src/auth/SessionStore.test.ts +++ b/apps/server/src/auth/SessionStore.test.ts @@ -1,4 +1,5 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; +import { EnvironmentId } from "@t3tools/contracts"; import { expect, it } from "@effect/vitest"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -7,15 +8,14 @@ import * as TestClock from "effect/testing/TestClock"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as ServerConfig from "../config.ts"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import { PersistenceSqlError } from "../persistence/Errors.ts"; import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; import * as AuthSessions from "../persistence/AuthSessions.ts"; import * as SessionStore from "./SessionStore.ts"; import * as ServerSecretStore from "./ServerSecretStore.ts"; -const makeServerConfigLayer = ( - overrides?: Partial>, -) => +const makeServerConfigLayer = (overrides?: Partial) => Layer.effect( ServerConfig.ServerConfig, Effect.gen(function* () { @@ -27,12 +27,19 @@ const makeServerConfigLayer = ( }), ).pipe(Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-auth-session-test-" }))); +const makeServerEnvironmentLayer = (environmentId: EnvironmentId) => + Layer.succeed(ServerEnvironment.ServerEnvironmentIdentity, { + getEnvironmentId: Effect.succeed(environmentId), + }); + const makeSessionStoreLayer = ( - overrides?: Partial>, + overrides?: Partial, + environmentId = EnvironmentId.make("test-environment"), ) => SessionStore.layer.pipe( Layer.provide(SqlitePersistenceMemory), Layer.provide(ServerSecretStore.layer), + Layer.provide(makeServerEnvironmentLayer(environmentId)), Layer.provide(makeServerConfigLayer(overrides)), ); @@ -58,10 +65,32 @@ const failingSessionLookupCredentialLayer = Layer.effect( Layer.provide(failingSessionLookupRepositoryLayer), Layer.provide(ServerSecretStore.layer), Layer.provide(SqlitePersistenceMemory), + Layer.provide(makeServerEnvironmentLayer(EnvironmentId.make("test-environment"))), Layer.provide(makeServerConfigLayer()), ); it.layer(NodeServices.layer)("SessionStore.layer", (it) => { + it.effect("keys remote cookies by environment identity instead of state directory", () => + Effect.gen(function* () { + const cookieName = (stateDir: string, environmentId: EnvironmentId) => + Effect.gen(function* () { + const sessions = yield* SessionStore.SessionStore; + return sessions.cookieName; + }).pipe( + Effect.provide( + makeSessionStoreLayer({ mode: "web", host: "192.168.1.50", stateDir }, environmentId), + ), + ); + + const original = yield* cookieName("/srv/t3-one", EnvironmentId.make("environment-one")); + const moved = yield* cookieName("/srv/t3-moved", EnvironmentId.make("environment-one")); + const other = yield* cookieName("/srv/t3-one", EnvironmentId.make("environment-two")); + + expect(moved).toBe(original); + expect(other).not.toBe(original); + }), + ); + it.effect("issues and verifies signed browser session tokens", () => Effect.gen(function* () { const sessions = yield* SessionStore.SessionStore; diff --git a/apps/server/src/auth/SessionStore.ts b/apps/server/src/auth/SessionStore.ts index cdcd4a1ac198..d4fbe445edf6 100644 --- a/apps/server/src/auth/SessionStore.ts +++ b/apps/server/src/auth/SessionStore.ts @@ -21,11 +21,13 @@ import * as Stream from "effect/Stream"; import * as Option from "effect/Option"; import * as ServerConfig from "../config.ts"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import * as AuthSessions from "../persistence/AuthSessions.ts"; import * as ServerSecretStore from "./ServerSecretStore.ts"; import { base64UrlDecodeUtf8, base64UrlEncode, + resolveLegacySessionCookieName, resolveSessionCookieName, signPayload, timingSafeEqualBase64Url, @@ -360,6 +362,7 @@ export class SessionStore extends Context.Service< SessionStore, { readonly cookieName: string; + readonly legacyCookieName: string | undefined; readonly issue: (input?: { readonly ttl?: Duration.Duration; readonly subject?: string; @@ -470,18 +473,22 @@ function toAuthClientSession(input: Omit): AuthCli export const make = Effect.gen(function* () { const crypto = yield* Crypto.Crypto; const serverConfig = yield* ServerConfig.ServerConfig; + const serverEnvironment = yield* ServerEnvironment.ServerEnvironmentIdentity; const secretStore = yield* ServerSecretStore.ServerSecretStore; const authSessions = yield* AuthSessions.AuthSessionRepository; const signingSecret = yield* secretStore.getOrCreateRandom(SIGNING_SECRET_NAME, 32); const connectedSessionsRef = yield* Ref.make(new Map()); const changesPubSub = yield* PubSub.unbounded(); - const cookieName = resolveSessionCookieName({ + const cookieInput = { mode: serverConfig.mode, port: serverConfig.port, host: serverConfig.host, instanceKey: serverConfig.stateDir, + environmentId: yield* serverEnvironment.getEnvironmentId, development: serverConfig.devUrl !== undefined, - }); + } as const; + const cookieName = resolveSessionCookieName(cookieInput); + const legacyCookieName = resolveLegacySessionCookieName(cookieInput); const emitUpsert = (clientSession: AuthClientSession) => PubSub.publish(changesPubSub, { @@ -930,6 +937,7 @@ export const make = Effect.gen(function* () { return SessionStore.of({ cookieName, + legacyCookieName, issue, verify, issueWebSocketToken, diff --git a/apps/server/src/auth/dpop.test.ts b/apps/server/src/auth/dpop.test.ts index fa75c407b0c6..ea8d1cd99db7 100644 --- a/apps/server/src/auth/dpop.test.ts +++ b/apps/server/src/auth/dpop.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vite-plus/test"; import * as PlatformError from "effect/PlatformError"; import { SecretStorePersistError } from "./ServerSecretStore.ts"; -import { mapDpopReplayStoreError } from "./dpop.ts"; +import { mapDpopFailureReason, mapDpopReplayStoreError } from "./dpop.ts"; const storeFailure = (tag: "AlreadyExists" | "PermissionDenied") => new SecretStorePersistError({ @@ -23,6 +23,7 @@ describe("mapDpopReplayStoreError", () => { expect(error._tag).toBe("ServerAuthInvalidCredentialError"); if (error._tag === "ServerAuthInvalidCredentialError") { expect(error.cause).toBe(cause); + expect(error.dpopFailureReason).toBe("replay"); } }); @@ -35,3 +36,23 @@ describe("mapDpopReplayStoreError", () => { } }); }); + +describe("mapDpopFailureReason", () => { + it("maps verifier failures to safe client-facing categories", () => { + const mappings = [ + ["time_window", "time_window"], + ["key_mismatch", "key_mismatch"], + ["method_mismatch", "request_mismatch"], + ["url_mismatch", "request_mismatch"], + ["access_token_hash_mismatch", "token_mismatch"], + ["missing_proof", "invalid_proof"], + ["malformed_proof", "invalid_proof"], + ["invalid_signature", "invalid_proof"], + ["invalid_proof", "invalid_proof"], + ] as const; + + for (const [code, expected] of mappings) { + expect(mapDpopFailureReason(code)).toBe(expected); + } + }); +}); diff --git a/apps/server/src/auth/dpop.ts b/apps/server/src/auth/dpop.ts index f19984eb3690..43f90e440915 100644 --- a/apps/server/src/auth/dpop.ts +++ b/apps/server/src/auth/dpop.ts @@ -1,4 +1,8 @@ -import { verifyDpopProof } from "@t3tools/shared/dpop"; +import { + type DpopVerificationFailureCode as DpopVerificationFailureCodeType, + verifyDpopProof, +} from "@t3tools/shared/dpop"; +import type { DpopFailureReason } from "@t3tools/contracts"; import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; @@ -14,12 +18,32 @@ import { } from "./EnvironmentAuth.ts"; import * as ServerSecretStore from "./ServerSecretStore.ts"; +export const mapDpopFailureReason = (code: DpopVerificationFailureCodeType): DpopFailureReason => { + switch (code) { + case "time_window": + return "time_window"; + case "key_mismatch": + return "key_mismatch"; + case "method_mismatch": + case "url_mismatch": + return "request_mismatch"; + case "access_token_hash_mismatch": + return "token_mismatch"; + case "missing_proof": + case "malformed_proof": + case "invalid_signature": + case "invalid_proof": + return "invalid_proof"; + } +}; + export const mapDpopReplayStoreError = ( error: ServerSecretStore.SecretStoreError, ): ServerAuthInvalidCredentialError | ServerAuthInternalError => ServerSecretStore.isSecretAlreadyExistsError(error) ? new ServerAuthInvalidCredentialError({ diagnostic: "DPoP proof replayed.", + dpopFailureReason: "replay", cause: error, }) : new ServerAuthDpopReplayStateRecordError({ @@ -49,8 +73,12 @@ export const verifyRequestDpopProof = (input: { ...(input.expectedAccessToken ? { expectedAccessToken: input.expectedAccessToken } : {}), }); if (!result.ok) { + yield* Effect.annotateCurrentSpan({ + "environment.dpop.failure_code": result.code, + }); return yield* new ServerAuthInvalidCredentialError({ diagnostic: result.reason, + dpopFailureReason: mapDpopFailureReason(result.code), }); } const secretStore = yield* ServerSecretStore.ServerSecretStore; @@ -80,7 +108,15 @@ export const verifyRequestDpopProof = (input: { ) .pipe( Effect.catchIf(ServerSecretStore.isSecretStoreError, (error) => - Effect.fail(mapDpopReplayStoreError(error)), + Effect.gen(function* () { + const mapped = mapDpopReplayStoreError(error); + if (mapped._tag === "ServerAuthInvalidCredentialError") { + yield* Effect.annotateCurrentSpan({ + "environment.dpop.failure_code": mapped.dpopFailureReason, + }); + } + return yield* Effect.fail(mapped); + }), ), ); return result.thumbprint; diff --git a/apps/server/src/auth/http.ts b/apps/server/src/auth/http.ts index 780aaabde251..cc74966c41e2 100644 --- a/apps/server/src/auth/http.ts +++ b/apps/server/src/auth/http.ts @@ -22,7 +22,7 @@ import { EnvironmentAuthenticatedAuth, EnvironmentAuthenticatedPrincipal, } from "@t3tools/contracts"; -import type { AuthEnvironmentScope } from "@t3tools/contracts"; +import type { AuthEnvironmentScope, DpopFailureReason } from "@t3tools/contracts"; import { parseAllowedOAuthScope } from "@t3tools/shared/oauthScope"; import { causeErrorTag } from "@t3tools/shared/observability"; import * as DateTime from "effect/DateTime"; @@ -95,10 +95,20 @@ export function annotateEnvironmentRequest(endpoint: string) { }); } -export function failEnvironmentAuthInvalid(reason: EnvironmentAuthInvalidReason) { +export function failEnvironmentAuthInvalid( + reason: EnvironmentAuthInvalidReason, + dpopFailureReason?: DpopFailureReason, +) { return currentEnvironmentTraceId.pipe( Effect.flatMap((traceId) => - Effect.fail(new EnvironmentAuthInvalidError({ code: "auth_invalid", reason, traceId })), + Effect.fail( + new EnvironmentAuthInvalidError({ + code: "auth_invalid", + reason, + ...(dpopFailureReason === undefined ? {} : { dpopFailureReason }), + traceId, + }), + ), ), ); } @@ -161,6 +171,23 @@ export function failEnvironmentInternal(reason: EnvironmentInternalErrorReason, }); } +const appendSessionCookie = (cookieName: string, token: string, expiresAt: DateTime.DateTime) => + Effect.fromResult( + Cookies.set(Cookies.empty, cookieName, token, { + expires: DateTime.toDate(expiresAt), + httpOnly: true, + path: "/", + sameSite: "lax", + }), + ).pipe( + Effect.catch(() => failEnvironmentInternal("browser_session_cookie_failed")), + Effect.flatMap((cookies) => + HttpEffect.appendPreResponseHandler((_request, response) => + Effect.succeed(HttpServerResponse.mergeCookies(response, cookies)), + ), + ), + ); + export const requireEnvironmentScope = Effect.fn("environment.auth.requireScope")(function* ( scope: AuthEnvironmentScope, ) { @@ -180,7 +207,10 @@ export const environmentAuthenticatedAuthLayer = Layer.effect( const request = yield* HttpServerRequest.HttpServerRequest; const session = yield* serverAuth.authenticateHttpRequest(request).pipe( Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) => - failEnvironmentAuthInvalid(EnvironmentAuth.serverAuthCredentialReason(error)), + failEnvironmentAuthInvalid( + EnvironmentAuth.serverAuthCredentialReason(error), + EnvironmentAuth.serverAuthDpopFailureReason(error), + ), ), Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => failEnvironmentInternal("internal_error", error), @@ -211,7 +241,22 @@ export const authHttpApiLayer = HttpApiBuilder.group( function* (args) { yield* annotateEnvironmentRequest(args.endpoint.name); const request = yield* HttpServerRequest.HttpServerRequest; - return yield* serverAuth.getSessionState(request); + const result = yield* serverAuth.getSessionState(request); + const credential = EnvironmentAuth.selectRequestCredential( + request, + sessions.cookieName, + sessions.legacyCookieName, + ); + if ( + credential?.source === "legacy-cookie" && + result.authenticated && + result.sessionMethod === "browser-session-cookie" && + result.expiresAt + ) { + yield* appendSessionCookie(sessions.cookieName, credential.token, result.expiresAt); + yield* appendCredentialResponseHeaders; + } + return result; }, Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => failEnvironmentInternal("internal_error", error), @@ -228,23 +273,19 @@ export const authHttpApiLayer = HttpApiBuilder.group( args.payload.credential, deriveAuthClientMetadata({ request }), ); - const sessionCookies = yield* Effect.fromResult( - Cookies.set(Cookies.empty, sessions.cookieName, result.sessionToken, { - expires: DateTime.toDate(result.response.expiresAt), - httpOnly: true, - path: "/", - sameSite: "lax", - }), - ).pipe(Effect.catch(() => failEnvironmentInternal("browser_session_cookie_failed"))); - - yield* HttpEffect.appendPreResponseHandler((_request, response) => - Effect.succeed(HttpServerResponse.mergeCookies(response, sessionCookies)), + yield* appendSessionCookie( + sessions.cookieName, + result.sessionToken, + result.response.expiresAt, ); yield* appendCredentialResponseHeaders; return result.response; }, Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) => - failEnvironmentAuthInvalid(EnvironmentAuth.serverAuthCredentialReason(error)), + failEnvironmentAuthInvalid( + EnvironmentAuth.serverAuthCredentialReason(error), + EnvironmentAuth.serverAuthDpopFailureReason(error), + ), ), Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => failEnvironmentInternal("browser_session_issuance_failed", error), @@ -278,9 +319,14 @@ export const authHttpApiLayer = HttpApiBuilder.group( } const proofKeyThumbprint = args.headers.dpop ? yield* verifyRequestDpopProof({ request }).pipe( - Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, () => + Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) => appendDpopChallengeHeader.pipe( - Effect.andThen(failEnvironmentAuthInvalid("invalid_credential")), + Effect.andThen( + failEnvironmentAuthInvalid( + "invalid_credential", + EnvironmentAuth.serverAuthDpopFailureReason(error), + ), + ), ), ), Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => @@ -307,7 +353,10 @@ export const authHttpApiLayer = HttpApiBuilder.group( }, traceRelayRequest, Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) => - failEnvironmentAuthInvalid(EnvironmentAuth.serverAuthCredentialReason(error)), + failEnvironmentAuthInvalid( + EnvironmentAuth.serverAuthCredentialReason(error), + EnvironmentAuth.serverAuthDpopFailureReason(error), + ), ), Effect.catchIf(EnvironmentAuth.isServerAuthInvalidRequestError, (error) => failEnvironmentInvalidRequest(EnvironmentAuth.serverAuthInvalidRequestReason(error)), diff --git a/apps/server/src/auth/utils.test.ts b/apps/server/src/auth/utils.test.ts index edc58f71131f..aebc9df5f437 100644 --- a/apps/server/src/auth/utils.test.ts +++ b/apps/server/src/auth/utils.test.ts @@ -64,6 +64,7 @@ describe("session cookie isolation", () => { port: 5775, host: "127.0.0.1", instanceKey: "/tmp/t3-agent-one", + environmentId: "environment-one", development: true, }); const second = resolveSessionCookieName({ @@ -71,6 +72,7 @@ describe("session cookie isolation", () => { port: 5775, host: "127.0.0.1", instanceKey: "/tmp/t3-agent-two", + environmentId: "environment-two", development: true, }); @@ -79,25 +81,48 @@ describe("session cookie isolation", () => { expect(first).not.toBe(second); }); - it("keeps the hosted web cookie stable across server instances", () => { - expect( - resolveSessionCookieName({ - mode: "web", - port: 8080, - host: "0.0.0.0", - instanceKey: "/srv/release-a", - development: false, - }), - ).toBe("t3_session"); - expect( - resolveSessionCookieName({ - mode: "web", - port: 9090, - host: "app.example.com", - instanceKey: "/srv/release-b", - development: false, - }), - ).toBe("t3_session"); + it("isolates remote web servers by server state", () => { + const first = resolveSessionCookieName({ + mode: "web", + port: 3773, + host: "192.168.1.50", + instanceKey: "/srv/t3-one", + environmentId: "environment-one", + development: false, + }); + const second = resolveSessionCookieName({ + mode: "web", + port: 5775, + host: "192.168.1.50", + instanceKey: "/srv/t3-two", + environmentId: "environment-two", + development: false, + }); + + expect(first).toMatch(/^t3_session_[a-f0-9]{12}$/); + expect(second).toMatch(/^t3_session_[a-f0-9]{12}$/); + expect(first).not.toBe(second); + }); + + it("keeps a remote web server cookie stable across port changes", () => { + const first = resolveSessionCookieName({ + mode: "web", + port: 8080, + host: "0.0.0.0", + instanceKey: "/srv/t3", + environmentId: "environment-one", + development: false, + }); + const second = resolveSessionCookieName({ + mode: "web", + port: 9090, + host: "app.example.com", + instanceKey: "/srv/t3", + environmentId: "environment-one", + development: false, + }); + + expect(first).toBe(second); }); it("retains desktop port scoping", () => { @@ -107,6 +132,7 @@ describe("session cookie isolation", () => { port: 3773, host: "127.0.0.1", instanceKey: "/tmp/desktop", + environmentId: "environment-one", development: true, }), ).toBe("t3_session_3773"); @@ -119,6 +145,7 @@ describe("session cookie isolation", () => { port: 5775, host: "0.0.0.0", instanceKey: "/tmp/t3-wildcard-dev", + environmentId: "environment-one", development: true, }), ).toMatch(/^t3_session_5775_[a-f0-9]{12}$/); diff --git a/apps/server/src/auth/utils.ts b/apps/server/src/auth/utils.ts index 32a6799b01f4..30d59d654010 100644 --- a/apps/server/src/auth/utils.ts +++ b/apps/server/src/auth/utils.ts @@ -16,40 +16,53 @@ const SESSION_COOKIE_NAME = "t3_session"; * clobbers the first's session and both sides see "Invalid session token * signature" until someone clears cookies by hand. * - * Two populations qualify, for the same reason but from different causes: + * Remote web servers use their persisted environment identity and omit the + * port, so the name survives state-directory moves and public port changes. * - * - **Dev servers** (`devUrl` set), which run several at a time across worktrees. - * - **Desktop**, which scans upward from 3773 for a free port and binds + * Desktop scans upward from 3773 for a free port and binds * 127.0.0.1, so a second instance lands on a different port and the same host. - * - * Hosted deployments keep the stable production name: their public port can - * change between releases, and scoping it would log every user out. */ export function resolveSessionCookieName(input: { readonly mode: "web" | "desktop"; readonly port: number; readonly host: string | undefined; readonly instanceKey: string; + readonly environmentId: string; readonly development: boolean; }): string { if (input.mode === "desktop") { return `${SESSION_COOKIE_NAME}_${input.port}`; } + const instanceHash = NodeCrypto.createHash("sha256") + .update( + !input.development && isRemoteReachableHost(input.host) + ? input.environmentId + : input.instanceKey, + ) + .digest("hex") + .slice(0, 12); + if (!input.development && isRemoteReachableHost(input.host)) { - return SESSION_COOKIE_NAME; + return `${SESSION_COOKIE_NAME}_${instanceHash}`; } // Cookies are scoped by host, not port. Loopback development servers need an // instance-specific name or parallel agents overwrite each other's session, // and a server that later reuses the port receives a token signed elsewhere. - const instanceHash = NodeCrypto.createHash("sha256") - .update(input.instanceKey) - .digest("hex") - .slice(0, 12); return `${SESSION_COOKIE_NAME}_${input.port}_${instanceHash}`; } +export function resolveLegacySessionCookieName(input: { + readonly mode: "web" | "desktop"; + readonly host: string | undefined; + readonly development: boolean; +}): string | undefined { + return input.mode === "web" && !input.development && isRemoteReachableHost(input.host) + ? SESSION_COOKIE_NAME + : undefined; +} + export function isRemoteReachableHost(host: string | undefined): boolean { if (host === "0.0.0.0" || host === "::" || host === "[::]") { return true; diff --git a/apps/server/src/bin.test.ts b/apps/server/src/bin.test.ts index fcb662b9b780..0deb261dbf9e 100644 --- a/apps/server/src/bin.test.ts +++ b/apps/server/src/bin.test.ts @@ -13,6 +13,7 @@ import { ThreadId, } from "@t3tools/contracts"; import * as NetService from "@t3tools/shared/Net"; +import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as DateTime from "effect/DateTime"; @@ -26,7 +27,13 @@ import * as TestConsole from "effect/testing/TestConsole"; import { Command } from "effect/unstable/cli"; import { cli, makeCli } from "./bin.ts"; +import * as ServiceLauncherClient from "./cloud/serviceLauncherClient.ts"; +import { + SERVICE_LAUNCHER_CONTEXT_ENV, + SERVICE_LAUNCHER_PROTOCOL, +} from "./cloud/serviceProtocol.ts"; import * as ServerConfig from "./config.ts"; +import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts"; import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts"; import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts"; @@ -42,7 +49,24 @@ import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; import { environmentAuthenticatedAuthLayer } from "./auth/http.ts"; +import packageJson from "../package.json" with { type: "json" }; + const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer); +const DisconnectedLauncherChildLayer = Layer.mergeAll( + Layer.succeed(HostProcessEnvironment, { + ...process.env, + [SERVICE_LAUNCHER_CONTEXT_ENV]: JSON.stringify({ + protocol: SERVICE_LAUNCHER_PROTOCOL, + childVersion: packageJson.version, + }), + }), + Layer.succeed(ServiceLauncherClient.ServiceLauncherHostProcess, { + connected: false, + send: () => false, + on: () => undefined, + off: () => undefined, + }), +); class ProjectCliHttpApi extends HttpApi.make("environment").add(EnvironmentOrchestrationHttpApi) {} const connectCli = makeCli({ cloudEnabled: true }); @@ -127,6 +151,7 @@ const withLiveProjectCliServer = (baseDir: string, run: () => Effect.Ef Layer.provideMerge( EnvironmentAuth.layer.pipe( Layer.provideMerge(SqlitePersistenceLayerLive), + Layer.provide(ServerEnvironment.identityLayer), Layer.provide(ServerSecretStore.layer), ), ), @@ -162,11 +187,19 @@ const withLiveProjectCliServer = (baseDir: string, run: () => Effect.Ef it.layer(NodeServices.layer)("bin cli parsing", (it) => { it.effect("accepts the built-in lowercase log-level flag values", () => - runCliWithRuntime(["--log-level", "debug", "--version"]), + Effect.gen(function* () { + const { output } = yield* captureStdout(runCli(["--log-level", "debug", "--version"])); + + assert.include(output, "0.0.0"); + }), ); it.effect("accepts canonical --no- boolean negation", () => - runCliWithRuntime(["--no-log-websocket-events", "--version"]), + Effect.gen(function* () { + const { output } = yield* captureStdout(runCli(["--no-log-websocket-events", "--version"])); + + assert.include(output, "0.0.0"); + }), ); it.effect("rejects invalid log-level casing before launching the server", () => @@ -237,7 +270,7 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => { assert.equal(status.linked, false); assert.equal(status.cloudUserId, null); assert.equal(status.relayUrl, null); - }), + }).pipe(Effect.provide(DisconnectedLauncherChildLayer)), ); it.effect("reports actionable human-readable headless connect state", () => @@ -408,7 +441,7 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => { "relay:write", ]); assert.equal("token" in (listed[0] ?? {}), false); - }), + }).pipe(Effect.provide(DisconnectedLauncherChildLayer)), ); it.effect("rejects invalid ttl values before running auth commands", () => diff --git a/apps/server/src/bin.ts b/apps/server/src/bin.ts index 8d2ee75acf2e..0a2e4091560b 100644 --- a/apps/server/src/bin.ts +++ b/apps/server/src/bin.ts @@ -8,6 +8,7 @@ import * as CliError from "effect/unstable/cli/CliError"; import * as NetService from "@t3tools/shared/Net"; import packageJson from "../package.json" with { type: "json" }; import { authCommand } from "./cli/auth.ts"; +import { appCommand } from "./cli/app.ts"; import { connectCommand } from "./cli/connect.ts"; import { pairCommand } from "./cli/pair.ts"; import { hasCloudPublicConfig } from "./cloud/publicConfig.ts"; @@ -17,6 +18,7 @@ import { projectCommand } from "./cli/project.ts"; import { runServerCommand, serveCommand, startCommand } from "./cli/server.ts"; import { serviceCommand } from "./cli/service.ts"; import { servicePreflightCommand } from "./cli/servicePreflight.ts"; +import { themeCommand } from "./cli/theme.ts"; import { triageCommand } from "./cli/triage.ts"; const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer); @@ -52,11 +54,13 @@ export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) => Command.withSubcommands([ startCommand, serveCommand, + appCommand, pairCommand, authCommand, projectCommand, serviceCommand, servicePreflightCommand, + themeCommand, triageCommand, cloudEnabled ? connectCommand : connectUnavailableCommand, ]), diff --git a/apps/server/src/checkpointing/Errors.test.ts b/apps/server/src/checkpointing/Errors.test.ts deleted file mode 100644 index 4c8b9c59cc31..000000000000 --- a/apps/server/src/checkpointing/Errors.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { expect, it } from "@effect/vitest"; -import { ThreadId } from "@t3tools/contracts"; - -import { - CheckpointRefUnavailableError, - CheckpointTurnRangeUnavailableError, - CheckpointWorkspacePathMissingError, -} from "./Errors.ts"; - -const threadId = ThreadId.make("thread-1"); - -it("derives checkpoint messages from structured context", () => { - const range = new CheckpointTurnRangeUnavailableError({ - operation: "CheckpointDiffQuery.getTurnDiff", - threadId, - requestedTurnCount: 4, - availableTurnCount: 2, - }); - const checkpoint = new CheckpointRefUnavailableError({ - operation: "CheckpointDiffQuery.getTurnDiff", - threadId, - turnCount: 2, - checkpoint: "to", - }); - const workspace = new CheckpointWorkspacePathMissingError({ - operation: "CheckpointDiffQuery.getFullThreadDiff", - threadId, - }); - - expect(range.message).toBe( - "Checkpoint unavailable for thread thread-1 turn 4: Turn diff range exceeds current turn count: requested 4, current 2.", - ); - expect(checkpoint.message).toBe( - "Checkpoint unavailable for thread thread-1 turn 2: Checkpoint ref is unavailable for turn 2.", - ); - expect(workspace.message).toBe( - "Checkpoint invariant violation in CheckpointDiffQuery.getFullThreadDiff: Workspace path missing for thread 'thread-1' when computing full thread diff.", - ); -}); diff --git a/apps/server/src/cli/app.test.ts b/apps/server/src/cli/app.test.ts new file mode 100644 index 000000000000..0dddca4b1bf0 --- /dev/null +++ b/apps/server/src/cli/app.test.ts @@ -0,0 +1,307 @@ +// @effect-diagnostics nodeBuiltinImport:off -- The integration fixture binds the same platform socket or named pipe as the CLI. +import * as NodeFSP from "node:fs/promises"; +import * as NodeNet from "node:net"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { it } from "@effect/vitest"; +import type { DesktopAppActivationRequest } from "@t3tools/contracts"; +import { resolveDesktopAppControlAddress } from "@t3tools/shared/desktopAppControl"; +import { + HostProcessPlatform, + HostProcessUserId, + HostProcessWorkingDirectory, +} from "@t3tools/shared/hostProcess"; +import * as NetService from "@t3tools/shared/Net"; +import * as ConfigProvider from "effect/ConfigProvider"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { Command } from "effect/unstable/cli"; +import { afterEach, describe, expect, vi } from "vite-plus/test"; + +import { makeCli } from "../bin.ts"; + +vi.mock("node:os", async (importOriginal) => { + const os = await importOriginal(); + return { ...os, homedir: vi.fn(os.homedir) }; +}); + +afterEach(() => vi.mocked(NodeOS.homedir).mockReset()); + +const runCli = (args: ReadonlyArray, env: Record = {}) => + Command.runWith(makeCli(), { version: "0.0.0" })(args).pipe( + Effect.provide( + Layer.mergeAll( + NodeServices.layer, + NetService.layer, + ConfigProvider.layer(ConfigProvider.fromEnv({ env })), + ), + ), + ); + +const pathExists = (path: string) => + Effect.promise(() => + NodeFSP.stat(path).then( + () => true, + () => false, + ), + ); + +async function startFakeDesktop(input: { + readonly baseDir: string; + readonly stateSubdirectory?: "userdata" | "dev"; + readonly platform: NodeJS.Platform; + readonly userId: number | undefined; + readonly reply?: (request: DesktopAppActivationRequest) => unknown; +}) { + const target = resolveDesktopAppControlAddress({ + stateDir: NodePath.join(input.baseDir, input.stateSubdirectory ?? "userdata"), + platform: input.platform, + tempDir: NodeOS.tmpdir(), + userId: input.userId, + joinPath: NodePath.join, + }); + if (target.directory !== null) { + await NodeFSP.mkdir(target.directory, { recursive: true, mode: 0o700 }); + await NodeFSP.unlink(target.address).catch((error: NodeJS.ErrnoException) => { + if (error.code !== "ENOENT") throw error; + }); + } + + const received: DesktopAppActivationRequest[] = []; + const server = NodeNet.createServer((socket) => { + socket.setEncoding("utf8"); + let buffer = ""; + socket.on("data", (chunk) => { + buffer += chunk; + const newline = buffer.indexOf("\n"); + if (newline === -1) return; + const request = JSON.parse(buffer.slice(0, newline)) as DesktopAppActivationRequest; + received.push(request); + const response = input.reply + ? input.reply(request) + : { + version: 1, + requestId: request.requestId, + ok: true, + projectId: "project-1", + threadId: `thread-${received.length}`, + }; + socket.end(`${JSON.stringify(response)}\n`); + }); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(target.address, resolve); + }); + + return { + received, + close: async () => { + await new Promise((resolve) => server.close(() => resolve())); + if (target.directory !== null) { + await NodeFSP.unlink(target.address).catch((error: NodeJS.ErrnoException) => { + if (error.code !== "ENOENT") throw error; + }); + } + }, + }; +} + +const fakeDesktop = Effect.fn(function* ( + input: Omit[0], "platform" | "userId">, +) { + const platform = yield* HostProcessPlatform; + const userId = yield* HostProcessUserId; + return yield* Effect.acquireRelease( + Effect.promise(() => startFakeDesktop({ ...input, platform, userId })), + (server) => Effect.promise(() => server.close()), + ); +}); + +const withTempDirectory = ( + prefix: string, + use: (root: string) => Effect.Effect, +) => + Effect.acquireUseRelease( + Effect.promise(() => NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), prefix))), + use, + (root) => Effect.promise(() => NodeFSP.rm(root, { recursive: true, force: true })), + ); + +describe("t3 app", () => { + it.effect("rejects SSH before it tries to reach a desktop app", () => + withTempDirectory("t3-app-ssh-test-", (root) => + Effect.gen(function* () { + const baseDir = NodePath.join(root, "missing-t3-home"); + const error = yield* runCli(["app", "--base-dir", baseDir], { + SSH_CONNECTION: "client server", + }).pipe(Effect.flip); + + expect(error).toMatchObject({ + _tag: "DesktopAppSshUnsupportedError", + message: + "`t3 app` only controls a desktop app on the same machine. It cannot run over SSH.", + }); + expect(yield* pathExists(baseDir)).toBe(false); + }), + ), + ); + + it.effect("rejects unsupported platforms without creating state", () => + withTempDirectory("t3-app-platform-test-", (root) => + Effect.gen(function* () { + const baseDir = NodePath.join(root, "missing-t3-home"); + const error = yield* runCli(["app", "--base-dir", baseDir]).pipe( + Effect.provideService(HostProcessPlatform, "freebsd"), + Effect.flip, + ); + + expect(error).toMatchObject({ + _tag: "DesktopAppPlatformUnsupportedError", + platform: "freebsd", + message: "`t3 app` is not supported on freebsd.", + }); + expect(yield* pathExists(baseDir)).toBe(false); + }), + ), + ); + + it.effect("does not create state when only a server or no desktop app is running", () => + withTempDirectory("t3-app-missing-test-", (root) => + Effect.gen(function* () { + const baseDir = NodePath.join(root, "missing-t3-home"); + const error = yield* runCli(["app", "--base-dir", baseDir]).pipe(Effect.flip); + + expect(error).toMatchObject({ + _tag: "DesktopAppUnreachableError", + candidateAddresses: [expect.any(String)], + workspaceRoot: yield* HostProcessWorkingDirectory, + message: expect.stringContaining("Could not reach the T3 Code desktop app."), + cause: { code: "ENOENT" }, + }); + expect(yield* pathExists(baseDir)).toBe(false); + }), + ), + ); + + it.effect("uses T3CODE_HOME or --base-dir and sends the default or explicit path", () => + withTempDirectory("t3-app-command-test-", (root) => + Effect.gen(function* () { + const baseDir = NodePath.join(root, "t3-home"); + const explicitPath = NodePath.join(root, "project"); + const platform = yield* HostProcessPlatform; + const workingDirectory = yield* HostProcessWorkingDirectory; + const desktop = yield* fakeDesktop({ baseDir }); + + yield* runCli(["app"], { T3CODE_HOME: baseDir }); + yield* runCli(["app", explicitPath, "--base-dir", baseDir]); + + expect(desktop.received.map((request) => request.workspaceRoot)).toEqual([ + workingDirectory, + explicitPath, + ]); + expect(desktop.received.every((request) => request.platform === platform)).toBe(true); + }).pipe(Effect.scoped), + ), + ); + + it.effect("prefers the installed desktop app when a dev desktop is also running", () => + withTempDirectory("t3-app-preferred-test-", (root) => + Effect.gen(function* () { + vi.mocked(NodeOS.homedir).mockReturnValue(root); + const baseDir = NodePath.join(root, ".t3"); + const desktop = yield* fakeDesktop({ baseDir }); + const development = yield* fakeDesktop({ baseDir, stateSubdirectory: "dev" }); + + yield* runCli(["app"]); + + expect(desktop.received).toHaveLength(1); + expect(development.received).toHaveLength(0); + }).pipe(Effect.scoped), + ), + ); + + it.effect("finds the dev desktop when the default desktop socket is absent", () => + withTempDirectory("t3-app-dev-test-", (root) => + Effect.gen(function* () { + vi.mocked(NodeOS.homedir).mockReturnValue(root); + const baseDir = NodePath.join(root, ".t3"); + const development = yield* fakeDesktop({ baseDir, stateSubdirectory: "dev" }); + + yield* runCli(["app"]); + yield* runCli(["app"], { T3CODE_HOME: " " }); + + expect(development.received).toHaveLength(2); + expect(yield* pathExists(baseDir)).toBe(false); + }).pipe(Effect.scoped), + ), + ); + + it.effect("never searches a dev state directory for an explicit T3 home", () => + withTempDirectory("t3-app-explicit-test-", (root) => + Effect.gen(function* () { + vi.mocked(NodeOS.homedir).mockReturnValue(root); + const baseDir = NodePath.join(root, ".t3"); + const development = yield* fakeDesktop({ baseDir, stateSubdirectory: "dev" }); + + const flagError = yield* runCli(["app", "--base-dir", baseDir]).pipe(Effect.flip); + const envError = yield* runCli(["app"], { T3CODE_HOME: baseDir }).pipe(Effect.flip); + + expect(flagError).toMatchObject({ _tag: "DesktopAppUnreachableError" }); + expect(envError).toMatchObject({ _tag: "DesktopAppUnreachableError" }); + expect(development.received).toHaveLength(0); + }).pipe(Effect.scoped), + ), + ); + + for (const responseKind of ["failure", "invalid"] as const) { + it.effect(`never falls back after the default desktop sends a ${responseKind} response`, () => + withTempDirectory("t3-app-response-test-", (root) => + Effect.gen(function* () { + vi.mocked(NodeOS.homedir).mockReturnValue(root); + const baseDir = NodePath.join(root, ".t3"); + const desktop = yield* fakeDesktop({ + baseDir, + reply: (request) => + responseKind === "failure" + ? { + version: 1, + requestId: request.requestId, + ok: false, + code: "project-create-failed", + message: "The project path is not available.", + } + : { invalid: true }, + }); + const development = yield* fakeDesktop({ baseDir, stateSubdirectory: "dev" }); + + const error = yield* runCli(["app"]).pipe(Effect.flip); + + expect(desktop.received).toHaveLength(1); + expect(development.received).toHaveLength(0); + if (responseKind === "failure") { + expect(error).toMatchObject({ + _tag: "DesktopAppRequestFailedError", + code: "project-create-failed", + requestId: desktop.received[0]?.requestId, + workspaceRoot: yield* HostProcessWorkingDirectory, + message: expect.stringContaining("project-create-failed"), + cause: { + ok: false, + code: "project-create-failed", + message: "The project path is not available.", + }, + }); + } else { + expect(error).toMatchObject({ + _tag: "DesktopAppUnreachableError", + cause: { message: "The desktop app response is invalid." }, + }); + } + }).pipe(Effect.scoped), + ), + ); + } +}); diff --git a/apps/server/src/cli/app.ts b/apps/server/src/cli/app.ts new file mode 100644 index 000000000000..85fbebd74474 --- /dev/null +++ b/apps/server/src/cli/app.ts @@ -0,0 +1,261 @@ +// @effect-diagnostics globalTimers:off -- The Node socket client owns its response deadline and clears it on every completion path. +import * as NodeCrypto from "node:crypto"; +import * as NodeNet from "node:net"; +import * as NodeOS from "node:os"; + +import { + DESKTOP_APP_ACTIVATION_PROTOCOL_VERSION, + DesktopAppActivationErrorCode, + DesktopAppActivationResponse, + type DesktopAppActivationPlatform, + type DesktopAppActivationRequest, +} from "@t3tools/contracts"; +import { resolveDesktopAppControlAddress } from "@t3tools/shared/desktopAppControl"; +import { + HostProcessPlatform, + HostProcessUserId, + HostProcessWorkingDirectory, +} from "@t3tools/shared/hostProcess"; +import * as Config from "effect/Config"; +import * as Console from "effect/Console"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import { Argument, Command } from "effect/unstable/cli"; + +import { expandHomePath, resolveBaseDir } from "../os-jank.ts"; +import { baseDirFlag } from "./config.ts"; + +const CLI_RESPONSE_TIMEOUT_MS = 17_000; +const MAX_RESPONSE_BYTES = 64 * 1024; +const isDesktopAppActivationResponse = Schema.is(DesktopAppActivationResponse); + +export class DesktopAppSshUnsupportedError extends Schema.TaggedErrorClass()( + "DesktopAppSshUnsupportedError", + {}, +) { + override get message(): string { + return "`t3 app` only controls a desktop app on the same machine. It cannot run over SSH."; + } +} + +export class DesktopAppPlatformUnsupportedError extends Schema.TaggedErrorClass()( + "DesktopAppPlatformUnsupportedError", + { platform: Schema.String }, +) { + override get message(): string { + return `\`t3 app\` is not supported on ${this.platform}.`; + } +} + +export class DesktopAppUnreachableError extends Schema.TaggedErrorClass()( + "DesktopAppUnreachableError", + { + candidateAddresses: Schema.Array(Schema.String), + requestId: Schema.String, + workspaceRoot: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Could not reach the T3 Code desktop app. Start or update the desktop app on this machine, then run `t3 app` again. A running T3 Code server is not enough."; + } +} + +export class DesktopAppRequestFailedError extends Schema.TaggedErrorClass()( + "DesktopAppRequestFailedError", + { + code: DesktopAppActivationErrorCode, + requestId: Schema.String, + workspaceRoot: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `T3 Code could not open ${this.workspaceRoot} (${this.code}).`; + } +} + +function isDesktopPlatform(platform: NodeJS.Platform): platform is DesktopAppActivationPlatform { + return platform === "darwin" || platform === "linux" || platform === "win32"; +} + +export function sendDesktopAppActivationRequest(input: { + readonly address: string; + readonly fallbackAddress?: string; + readonly request: DesktopAppActivationRequest; + readonly timeoutMs?: number; +}): Promise { + return new Promise((resolve, reject) => { + const socket = NodeNet.createConnection(input.address); + socket.setEncoding("utf8"); + let buffer = ""; + let settled = false; + let connected = false; + + const finish = ( + result: + | { readonly type: "success"; readonly response: DesktopAppActivationResponse } + | { readonly type: "failure"; readonly error: Error }, + ) => { + if (settled) return; + settled = true; + clearTimeout(timeout); + socket.destroy(); + if (result.type === "success") resolve(result.response); + else reject(result.error); + }; + + const timeout = setTimeout(() => { + finish({ + type: "failure", + error: new Error("The desktop app did not respond in time."), + }); + }, input.timeoutMs ?? CLI_RESPONSE_TIMEOUT_MS); + + socket.once("connect", () => { + connected = true; + socket.write(`${JSON.stringify(input.request)}\n`); + }); + socket.on("data", (chunk) => { + buffer += chunk; + if (Buffer.byteLength(buffer, "utf8") > MAX_RESPONSE_BYTES) { + finish({ type: "failure", error: new Error("The desktop app response is too large.") }); + return; + } + const newline = buffer.indexOf("\n"); + if (newline === -1) return; + + let parsed: unknown; + try { + parsed = JSON.parse(buffer.slice(0, newline)); + } catch { + finish({ + type: "failure", + error: new Error("The desktop app response is not valid JSON."), + }); + return; + } + if (!isDesktopAppActivationResponse(parsed)) { + finish({ type: "failure", error: new Error("The desktop app response is invalid.") }); + return; + } + if (parsed.requestId !== input.request.requestId) { + finish({ + type: "failure", + error: new Error("The desktop app response did not match this request."), + }); + return; + } + finish({ type: "success", response: parsed }); + }); + socket.once("error", (error: NodeJS.ErrnoException) => { + if ( + !settled && + !connected && + input.fallbackAddress !== undefined && + (error.code === "ENOENT" || error.code === "ECONNREFUSED") + ) { + settled = true; + clearTimeout(timeout); + socket.destroy(); + resolve( + sendDesktopAppActivationRequest({ + address: input.fallbackAddress, + request: input.request, + ...(input.timeoutMs === undefined ? {} : { timeoutMs: input.timeoutMs }), + }), + ); + return; + } + finish({ type: "failure", error }); + }); + socket.once("end", () => { + finish({ type: "failure", error: new Error("The desktop app closed the connection.") }); + }); + }); +} + +const appEnvironment = Config.all({ + t3Home: Config.string("T3CODE_HOME").pipe(Config.option, Config.map(Option.getOrUndefined)), + sshConnection: Config.string("SSH_CONNECTION").pipe(Config.option), + sshTty: Config.string("SSH_TTY").pipe(Config.option), +}); + +const runAppCommand = Effect.fn("cli.app")(function* (flags: { + readonly baseDir: Option.Option; + readonly workspaceRoot: Option.Option; +}) { + const environment = yield* appEnvironment; + const hostPlatform = yield* HostProcessPlatform; + if (Option.isSome(environment.sshConnection) || Option.isSome(environment.sshTty)) { + return yield* new DesktopAppSshUnsupportedError({}); + } + if (!isDesktopPlatform(hostPlatform)) { + return yield* new DesktopAppPlatformUnsupportedError({ platform: hostPlatform }); + } + + const path = yield* Path.Path; + const configuredBaseDir = Option.getOrUndefined(flags.baseDir) ?? environment.t3Home; + const baseDir = yield* resolveBaseDir(configuredBaseDir); + const allowDevFallback = Option.isNone(flags.baseDir) && !environment.t3Home?.trim(); + const rawWorkspaceRoot = + Option.getOrUndefined(flags.workspaceRoot) ?? (yield* HostProcessWorkingDirectory); + const workspaceRoot = path.resolve(yield* expandHomePath(rawWorkspaceRoot)); + const userId = yield* HostProcessUserId; + const resolveAddress = (stateSubdirectory: "userdata" | "dev") => + resolveDesktopAppControlAddress({ + stateDir: path.join(baseDir, stateSubdirectory), + platform: hostPlatform, + tempDir: NodeOS.tmpdir(), + userId, + joinPath: path.join, + }).address; + const request: DesktopAppActivationRequest = { + version: DESKTOP_APP_ACTIVATION_PROTOCOL_VERSION, + requestId: NodeCrypto.randomUUID(), + type: "open-workspace", + workspaceRoot, + platform: hostPlatform, + }; + const address = resolveAddress("userdata"); + const fallbackAddress = allowDevFallback ? resolveAddress("dev") : undefined; + + const response = yield* Effect.tryPromise({ + try: () => + sendDesktopAppActivationRequest({ + address, + ...(fallbackAddress === undefined ? {} : { fallbackAddress }), + request, + }), + catch: (cause) => + new DesktopAppUnreachableError({ + candidateAddresses: fallbackAddress === undefined ? [address] : [address, fallbackAddress], + requestId: request.requestId, + workspaceRoot, + cause, + }), + }); + if (!response.ok) { + return yield* new DesktopAppRequestFailedError({ + code: response.code, + requestId: response.requestId, + workspaceRoot, + cause: response, + }); + } + + yield* Console.log(`Opened ${workspaceRoot} in T3 Code.`); +}); + +export const appCommand = Command.make("app", { + baseDir: baseDirFlag, + workspaceRoot: Argument.string("path").pipe( + Argument.withDescription("Project directory. Default: current directory."), + Argument.optional, + ), +}).pipe( + Command.withDescription("Open a project in the running T3 Code desktop app."), + Command.withHandler(runAppCommand), +); diff --git a/apps/server/src/cli/config.ts b/apps/server/src/cli/config.ts index 5b05b773b314..f739a4e2f22c 100644 --- a/apps/server/src/cli/config.ts +++ b/apps/server/src/cli/config.ts @@ -21,12 +21,12 @@ export const modeFlag = Flag.choice("mode", ServerConfig.RuntimeMode.literals).p Flag.withDescription("Runtime mode. `desktop` keeps loopback defaults unless overridden."), Flag.optional, ); -export const portFlag = Flag.integer("port").pipe( +const portFlag = Flag.integer("port").pipe( Flag.withSchema(PortSchema), Flag.withDescription("Port for the HTTP/WebSocket server."), Flag.optional, ); -export const hostFlag = Flag.string("host").pipe( +const hostFlag = Flag.string("host").pipe( Flag.withDescription("Host/interface to bind (for example 127.0.0.1, 0.0.0.0, or a Tailnet IP)."), Flag.optional, ); @@ -36,34 +36,34 @@ export const baseDirFlag = Flag.string("base-dir").pipe( ), Flag.optional, ); -export const devUrlFlag = Flag.string("dev-url").pipe( +const devUrlFlag = Flag.string("dev-url").pipe( Flag.withSchema(Schema.URLFromString), Flag.withDescription("Dev web URL to proxy/redirect to (equivalent to VITE_DEV_SERVER_URL)."), Flag.optional, ); -export const noBrowserFlag = Flag.boolean("no-browser").pipe( +const noBrowserFlag = Flag.boolean("no-browser").pipe( Flag.withDescription("Disable automatic browser opening."), Flag.optional, ); -export const bootstrapFdFlag = Flag.integer("bootstrap-fd").pipe( +const bootstrapFdFlag = Flag.integer("bootstrap-fd").pipe( Flag.withSchema(Schema.Int), Flag.withDescription("Read one-time bootstrap secrets from the given file descriptor."), Flag.optional, ); -export const autoBootstrapProjectFromCwdFlag = Flag.boolean("auto-bootstrap-project-from-cwd").pipe( +const autoBootstrapProjectFromCwdFlag = Flag.boolean("auto-bootstrap-project-from-cwd").pipe( Flag.withDescription( "Create a project for the current working directory on startup when missing.", ), Flag.optional, ); -export const logWebSocketEventsFlag = Flag.boolean("log-websocket-events").pipe( +const logWebSocketEventsFlag = Flag.boolean("log-websocket-events").pipe( Flag.withDescription( "Emit server-side logs for outbound WebSocket push traffic (equivalent to T3CODE_LOG_WS_EVENTS).", ), Flag.withAlias("log-ws-events"), Flag.optional, ); -export const tailscaleServeFlag = Flag.boolean("tailscale-serve").pipe( +const tailscaleServeFlag = Flag.boolean("tailscale-serve").pipe( Flag.withDescription( "Configure Tailscale Serve to expose this backend over HTTPS on the Tailnet.", ), @@ -161,7 +161,7 @@ export interface CliAuthLocationFlags { readonly devUrl?: Option.Option; } -export const sharedServerLocationFlags = { +export const authLocationFlags = { baseDir: baseDirFlag, devUrl: devUrlFlag, } as const; @@ -190,8 +190,6 @@ export const sharedServerCommandFlags = { tailscaleServePort: tailscaleServePortFlag, } as const; -export const authLocationFlags = sharedServerLocationFlags; - const resolveOptionPrecedence = ( ...values: ReadonlyArray> ): Option.Option => Option.firstSomeOf(values); diff --git a/apps/server/src/cli/connect.ts b/apps/server/src/cli/connect.ts index 74f469364aee..3f8e1d123da5 100644 --- a/apps/server/src/cli/connect.ts +++ b/apps/server/src/cli/connect.ts @@ -337,7 +337,7 @@ const unlinkRelayEnvironment = Effect.fn("cloud.cli.unlink_relay_environment")(f return { status: "not-authenticated" } satisfies RelayUnlinkResult; } - const environment = yield* ServerEnvironment.ServerEnvironment; + const environment = yield* ServerEnvironment.ServerEnvironmentIdentity; const environmentId = yield* environment.getEnvironmentId; const relayUrl = yield* relayUrlConfig; const httpClient = yield* HttpClient.HttpClient; @@ -432,7 +432,7 @@ const runCloudCommand = Effect.fn("cloud.cli.run_cloud_command")(function* , options?: { readonly quietLogs?: boolean; @@ -449,7 +449,6 @@ const runCloudCommand = Effect.fn("cloud.cli.run_cloud_command")(function* { assert.equal(credentials.length, 1); assert.equal(credentials[0]?.label, "t3 pair"); }), - ).pipe(Effect.provide(NodeServices.layer)), + ).pipe( + Effect.provide(NodeServices.layer), + Effect.provideService(HostProcessEnvironment, { + ...process.env, + [SERVICE_LAUNCHER_CONTEXT_ENV]: JSON.stringify({ + protocol: SERVICE_LAUNCHER_PROTOCOL, + childVersion: packageJson.version, + }), + }), + Effect.provideService(ServiceLauncherClient.ServiceLauncherHostProcess, { + connected: false, + send: () => false, + on: () => undefined, + off: () => undefined, + }), + ), ); it.effect("pairs through the recorded dev web URL for dev servers", () => diff --git a/apps/server/src/cli/pair.ts b/apps/server/src/cli/pair.ts index 38fa3be8bb57..d40e0d97e484 100644 --- a/apps/server/src/cli/pair.ts +++ b/apps/server/src/cli/pair.ts @@ -43,6 +43,7 @@ import * as ServerConfig from "../config.ts"; import { resolveBaseDir } from "../os-jank.ts"; import { type PersistedServerRuntimeState, + isProcessAlive, readPersistedServerRuntimeState, } from "../serverRuntimeState.ts"; import { @@ -229,17 +230,6 @@ const probeEnvironmentDescriptor = ( return { _tag: "descriptor", descriptor } as const; }).pipe(Effect.catch((outcome) => Effect.succeed(outcome))); -// signal 0 delivers nothing; it only reports whether the pid exists. EPERM -// means it exists but belongs to another user, which still counts as alive. -const isProcessAlive = (pid: number): boolean => { - try { - process.kill(pid, 0); - return true; - } catch (error) { - return error instanceof Error && "code" in error && error.code === "EPERM"; - } -}; - interface DiscoveredPairTarget { readonly baseDir: string; readonly variant: PairStateVariant; diff --git a/apps/server/src/cli/theme.test.ts b/apps/server/src/cli/theme.test.ts new file mode 100644 index 000000000000..d3dd69b94727 --- /dev/null +++ b/apps/server/src/cli/theme.test.ts @@ -0,0 +1,478 @@ +// @effect-diagnostics nodeBuiltinImport:off - CLI integration exercises the filesystem boundary. +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as ConfigProvider from "effect/ConfigProvider"; +import * as NetService from "@t3tools/shared/Net"; +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as TestConsole from "effect/testing/TestConsole"; +import { Command } from "effect/unstable/cli"; + +import { cli } from "../bin.ts"; + +const runCli = (args: ReadonlyArray) => + Command.runWith(cli, { version: "0.0.0" })(args).pipe( + Effect.provide(Layer.mergeAll(NodeServices.layer, NetService.layer, TestConsole.layer)), + ); + +const makeBaseDir = () => NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3code-theme-cli-")); + +const settingsPathFor = (baseDir: string) => NodePath.join(baseDir, "userdata", "settings.json"); + +const NIGHTFALL_THEME_JSON = `${JSON.stringify({ + name: "Nightfall", + appearance: "dark", + canvas: "#1a1b26", + accent: "#7aa2f7", +})}\n`; +const JUNK_THEME_JSON = `${JSON.stringify({ name: "Junk" })}\n`; + +const readSettings = (baseDir: string): Record => { + const raw = NodeFS.readFileSync(settingsPathFor(baseDir), "utf8"); + return JSON.parse(raw) as Record; +}; + +const writeSettings = (baseDir: string, settings: Record) => { + NodeFS.mkdirSync(NodePath.dirname(settingsPathFor(baseDir)), { recursive: true }); + NodeFS.writeFileSync(settingsPathFor(baseDir), `${JSON.stringify(settings, null, 2)}\n`); +}; + +describe("t3 theme", () => { + it.effect("writes a default theme when no settings file exists yet", () => + Effect.gen(function* () { + const baseDir = makeBaseDir(); + yield* runCli(["theme", "set", "ocean", "--base-dir", baseDir]); + assert.equal(readSettings(baseDir).defaultTheme, "ocean"); + }), + ); + + // A provisioning command runs against settings written by whatever version + // happens to be installed, so it must not drop what it cannot interpret. + it.effect("preserves settings it does not recognise", () => + Effect.gen(function* () { + const baseDir = makeBaseDir(); + writeSettings(baseDir, { + enableProviderUpdateChecks: false, + somethingFromANewerBuild: { nested: true }, + }); + + yield* runCli(["theme", "set", "ocean", "--base-dir", baseDir]); + + const settings = readSettings(baseDir); + assert.equal(settings.defaultTheme, "ocean"); + assert.equal(settings.enableProviderUpdateChecks, false); + assert.deepEqual(settings.somethingFromANewerBuild, { nested: true }); + }), + ); + + it.effect("clears the default back to leaving fresh clients alone", () => + Effect.gen(function* () { + const baseDir = makeBaseDir(); + writeSettings(baseDir, { enableProviderUpdateChecks: false }); + + yield* runCli(["theme", "set", "ocean", "--base-dir", baseDir]); + yield* runCli(["theme", "clear", "--base-dir", baseDir]); + + const settings = readSettings(baseDir); + assert.equal(Object.hasOwn(settings, "defaultTheme"), false); + assert.equal(settings.enableProviderUpdateChecks, false); + }), + ); + + // Publishing a file and pointing at it are one step, so an integration + // (a desktop's theme hook) needs no knowledge of the themes directory. + it.effect("publishes a theme file under its filename and sets it", () => + Effect.gen(function* () { + const baseDir = makeBaseDir(); + const themeFile = NodePath.join(baseDir, "nightfall.json"); + NodeFS.writeFileSync(themeFile, NIGHTFALL_THEME_JSON); + + yield* runCli(["theme", "set", themeFile, "--base-dir", baseDir]); + + const published = NodePath.join(baseDir, "userdata", "themes", "nightfall.json"); + assert.equal(NodeFS.existsSync(published), true); + assert.equal(readSettings(baseDir).defaultTheme, "nightfall"); + // No rollback or staging residue after a successful set. + const residue = NodeFS.readdirSync(NodePath.dirname(published)).filter( + (entry) => !entry.endsWith(".json"), + ); + assert.deepEqual(residue, []); + }), + ); + + it.effect("publishes a theme file under an explicit id", () => + Effect.gen(function* () { + const baseDir = makeBaseDir(); + const themeFile = NodePath.join(baseDir, "t3code.json"); + NodeFS.writeFileSync(themeFile, NIGHTFALL_THEME_JSON); + + yield* runCli(["theme", "set", "--id", "nightfall", themeFile, "--base-dir", baseDir]); + + assert.equal( + NodeFS.existsSync(NodePath.join(baseDir, "userdata", "themes", "nightfall.json")), + true, + ); + assert.equal(readSettings(baseDir).defaultTheme, "nightfall"); + }), + ); + + it.effect("rejects a file that is not a theme and sets nothing", () => + Effect.gen(function* () { + const baseDir = makeBaseDir(); + const themeFile = NodePath.join(baseDir, "junk.json"); + NodeFS.writeFileSync(themeFile, JUNK_THEME_JSON); + + const failure = yield* runCli(["theme", "set", themeFile, "--base-dir", baseDir]).pipe( + Effect.flip, + ); + + assert.include(String(failure), "not a valid theme file"); + assert.equal(NodeFS.existsSync(NodePath.join(baseDir, "userdata", "themes")), false); + assert.equal(NodeFS.existsSync(settingsPathFor(baseDir)), false); + }), + ); + + // Publish and set are one command, so a settings file the set step cannot + // use must fail it before the themes directory is mutated -- not after, + // with a half-applied publish left behind. + it.effect("publishes nothing when the settings file cannot be used", () => + Effect.gen(function* () { + const baseDir = makeBaseDir(); + NodeFS.mkdirSync(NodePath.dirname(settingsPathFor(baseDir)), { recursive: true }); + NodeFS.writeFileSync(settingsPathFor(baseDir), "{ not json"); + const themeFile = NodePath.join(baseDir, "nightfall.json"); + NodeFS.writeFileSync(themeFile, NIGHTFALL_THEME_JSON); + + const failure = yield* runCli(["theme", "set", themeFile, "--base-dir", baseDir]).pipe( + Effect.flip, + ); + + assert.include(String(failure), "not a JSON object"); + assert.equal(NodeFS.existsSync(NodePath.join(baseDir, "userdata", "themes")), false); + }), + ); + + // set means set: a publish that rode along with a failed default write is + // rolled back rather than left mutating the environment's theme set. The + // userdata directory is made read-only while themes stays writable, so the + // failure lands after the publish -- the case the rollback exists for. + it.effect("rolls back a publish when the default cannot be written", () => + Effect.gen(function* () { + const baseDir = makeBaseDir(); + writeSettings(baseDir, {}); + const userdataDir = NodePath.dirname(settingsPathFor(baseDir)); + const themesDir = NodePath.join(userdataDir, "themes"); + NodeFS.mkdirSync(themesDir, { recursive: true }); + const themeFile = NodePath.join(baseDir, "nightfall.json"); + NodeFS.writeFileSync(themeFile, NIGHTFALL_THEME_JSON); + + NodeFS.chmodSync(userdataDir, 0o555); + try { + const failure = yield* runCli(["theme", "set", themeFile, "--base-dir", baseDir]).pipe( + Effect.flip, + ); + assert.include(String(failure), "Could not write"); + assert.equal(NodeFS.existsSync(NodePath.join(themesDir, "nightfall.json")), false); + } finally { + NodeFS.chmodSync(userdataDir, 0o755); + } + }), + ); + + // A symlink is a normal way to hand this command a theme -- desktop hooks + // symlink the current palette -- so the source is resolved, not refused. + it.effect("publishes a theme file through a symlinked source path", () => + Effect.gen(function* () { + const baseDir = makeBaseDir(); + const realFile = NodePath.join(baseDir, "real-nightfall.json"); + NodeFS.writeFileSync(realFile, NIGHTFALL_THEME_JSON); + const linkPath = NodePath.join(baseDir, "nightfall.json"); + NodeFS.symlinkSync(realFile, linkPath); + + yield* runCli(["theme", "set", linkPath, "--base-dir", baseDir]); + + assert.equal( + NodeFS.existsSync(NodePath.join(baseDir, "userdata", "themes", "nightfall.json")), + true, + ); + assert.equal(readSettings(baseDir).defaultTheme, "nightfall"); + }), + ); + + // The staging entry is created fresh with O_EXCL, so a symlink planted at + // its predictable name is cleared, never followed and written through. + it.effect("never writes through a symlink at the staging path", () => + Effect.gen(function* () { + const baseDir = makeBaseDir(); + const themesDir = NodePath.join(baseDir, "userdata", "themes"); + NodeFS.mkdirSync(themesDir, { recursive: true }); + const victim = NodePath.join(baseDir, "victim.txt"); + NodeFS.writeFileSync(victim, "precious"); + NodeFS.symlinkSync(victim, NodePath.join(themesDir, `nightfall.json.staging-${process.pid}`)); + const themeFile = NodePath.join(baseDir, "nightfall.json"); + NodeFS.writeFileSync(themeFile, NIGHTFALL_THEME_JSON); + + yield* runCli(["theme", "set", themeFile, "--base-dir", baseDir]); + + assert.equal(NodeFS.readFileSync(victim, "utf8"), "precious"); + assert.equal(readSettings(baseDir).defaultTheme, "nightfall"); + }), + ); + + // Rollback moves the previous directory entry aside and back, so even an + // entry the watcher would never publish -- here a symlink -- comes back + // exactly as it was when the set fails. + it.effect("restores a non-theme destination entry when the set fails", () => + Effect.gen(function* () { + const baseDir = makeBaseDir(); + writeSettings(baseDir, {}); + const userdataDir = NodePath.dirname(settingsPathFor(baseDir)); + const themesDir = NodePath.join(userdataDir, "themes"); + NodeFS.mkdirSync(themesDir, { recursive: true }); + const outside = NodePath.join(baseDir, "outside.json"); + NodeFS.writeFileSync(outside, NIGHTFALL_THEME_JSON); + const destination = NodePath.join(themesDir, "nightfall.json"); + NodeFS.symlinkSync(outside, destination); + const themeFile = NodePath.join(baseDir, "nightfall.json"); + NodeFS.writeFileSync(themeFile, NIGHTFALL_THEME_JSON); + + NodeFS.chmodSync(userdataDir, 0o555); + try { + yield* runCli(["theme", "set", themeFile, "--base-dir", baseDir]).pipe(Effect.flip); + assert.equal(NodeFS.lstatSync(destination).isSymbolicLink(), true); + } finally { + NodeFS.chmodSync(userdataDir, 0o755); + } + }), + ); + + it.effect("restores the previous theme when a re-publish fails to set", () => + Effect.gen(function* () { + const baseDir = makeBaseDir(); + writeSettings(baseDir, {}); + const userdataDir = NodePath.dirname(settingsPathFor(baseDir)); + const themesDir = NodePath.join(userdataDir, "themes"); + NodeFS.mkdirSync(themesDir, { recursive: true }); + const publishedPath = NodePath.join(themesDir, "nightfall.json"); + const previous = + '{ "name": "Old Nightfall", "appearance": "dark", "canvas": "#000000", "accent": "#ffffff" }\n'; + NodeFS.writeFileSync(publishedPath, previous); + const themeFile = NodePath.join(baseDir, "nightfall.json"); + NodeFS.writeFileSync(themeFile, NIGHTFALL_THEME_JSON); + + NodeFS.chmodSync(userdataDir, 0o555); + try { + yield* runCli(["theme", "set", themeFile, "--base-dir", baseDir]).pipe(Effect.flip); + assert.equal(NodeFS.readFileSync(publishedPath, "utf8"), previous); + } finally { + NodeFS.chmodSync(userdataDir, 0o755); + } + }), + ); + + // A typo'd id written as the theme would silently never resolve anywhere; + // the id branch is as strict as the filename rule. + it.effect("rejects an id no client could resolve", () => + Effect.gen(function* () { + const baseDir = makeBaseDir(); + const failure = yield* runCli(["theme", "set", "Nightfall", "--base-dir", baseDir]).pipe( + Effect.flip, + ); + assert.include(String(failure), "not a valid theme id"); + assert.equal(NodeFS.existsSync(settingsPathFor(baseDir)), false); + }), + ); + + it.effect("rejects a path that does not exist instead of storing it as an id", () => + Effect.gen(function* () { + const baseDir = makeBaseDir(); + const failure = yield* runCli([ + "theme", + "set", + `${baseDir}/missing.json`, + "--base-dir", + baseDir, + ]).pipe(Effect.flip); + assert.include(String(failure), "Could not read"); + }), + ); + + // File-ness is decided by existence, not extension, so a generated file + // named for its target app still publishes. + it.effect("publishes an extensionless file", () => + Effect.gen(function* () { + const baseDir = makeBaseDir(); + const themeFile = NodePath.join(baseDir, "brand"); + NodeFS.writeFileSync(themeFile, NIGHTFALL_THEME_JSON); + + yield* runCli(["theme", "set", themeFile, "--base-dir", baseDir]); + + assert.equal( + NodeFS.existsSync(NodePath.join(baseDir, "userdata", "themes", "brand.json")), + true, + ); + assert.equal(readSettings(baseDir).defaultTheme, "brand"); + }), + ); + + it.effect("records a set generation and clears it with the theme", () => + Effect.gen(function* () { + const baseDir = makeBaseDir(); + yield* runCli(["theme", "set", "ocean", "--base-dir", baseDir]); + const setAt = readSettings(baseDir).defaultThemeSetAt; + assert.equal(typeof setAt, "string"); + + yield* runCli(["theme", "clear", "--base-dir", baseDir]); + const cleared = readSettings(baseDir); + assert.equal(Object.hasOwn(cleared, "defaultTheme"), false); + assert.equal(Object.hasOwn(cleared, "defaultThemeSetAt"), false); + }), + ); + + it.effect("honors T3CODE_HOME like the rest of the CLI", () => + Effect.gen(function* () { + const baseDir = makeBaseDir(); + yield* runCli(["theme", "set", "ocean"]).pipe( + Effect.provide( + ConfigProvider.layer(ConfigProvider.fromEnv({ env: { T3CODE_HOME: baseDir } })), + ), + ); + assert.equal(readSettings(baseDir).defaultTheme, "ocean"); + }), + ); + + // An unreadable settings file must never read as "no settings": writing a + // fresh sparse file over it would discard every key the user had. + it.effect("refuses to write when the settings file cannot be read", () => + Effect.gen(function* () { + const baseDir = makeBaseDir(); + writeSettings(baseDir, { enableProviderUpdateChecks: false }); + NodeFS.chmodSync(settingsPathFor(baseDir), 0o000); + + const failure = yield* runCli(["theme", "set", "ocean", "--base-dir", baseDir]).pipe( + Effect.flip, + ); + + NodeFS.chmodSync(settingsPathFor(baseDir), 0o644); + assert.include(String(failure), "Could not read"); + assert.equal(readSettings(baseDir).enableProviderUpdateChecks, false); + assert.equal(Object.hasOwn(readSettings(baseDir), "defaultTheme"), false); + }), + ); + + // A typo is syntactically a valid id, so shape validation alone would write + // a theme no client can resolve and report success. + it.effect("rejects an id that names no theme", () => + Effect.gen(function* () { + const baseDir = makeBaseDir(); + const failure = yield* runCli(["theme", "set", "ocian", "--base-dir", baseDir]).pipe( + Effect.flip, + ); + assert.include(String(failure), "No theme named"); + assert.equal(NodeFS.existsSync(settingsPathFor(baseDir)), false); + }), + ); + + it.effect("accepts an id a published file provides", () => + Effect.gen(function* () { + const baseDir = makeBaseDir(); + const themeFile = NodePath.join(baseDir, "nightfall.json"); + NodeFS.writeFileSync(themeFile, NIGHTFALL_THEME_JSON); + yield* runCli(["theme", "set", themeFile, "--base-dir", baseDir]); + + // Now resolvable by bare id, because the file published it. + yield* runCli(["theme", "clear", "--base-dir", baseDir]); + yield* runCli(["theme", "set", "nightfall", "--base-dir", baseDir]); + assert.equal(readSettings(baseDir).defaultTheme, "nightfall"); + }), + ); + + // The watcher skips files it cannot use, so accepting their filename would + // set a theme no client ever receives. + it.effect("rejects an id whose published file the watcher would skip", () => + Effect.gen(function* () { + const baseDir = makeBaseDir(); + const themesDir = NodePath.join(baseDir, "userdata", "themes"); + NodeFS.mkdirSync(themesDir, { recursive: true }); + NodeFS.writeFileSync(NodePath.join(themesDir, "broken.json"), "{ not json\n"); + + const failure = yield* runCli(["theme", "set", "broken", "--base-dir", baseDir]).pipe( + Effect.flip, + ); + assert.include(String(failure), "No theme named"); + }), + ); + + // Web and desktop cannot resolve the mobile default, and mobile does not + // follow this setting, so naming it would be a silent no-op. + it.effect("rejects the mobile default theme id", () => + Effect.gen(function* () { + const baseDir = makeBaseDir(); + const failure = yield* runCli(["theme", "set", "t3-code", "--base-dir", baseDir]).pipe( + Effect.flip, + ); + assert.include(String(failure), "No theme named"); + }), + ); + + // Deciding on existence alone would publish ./ocean instead of selecting the + // built-in, purely because of what happens to be in the working directory. + it.effect("treats a bare id as an id even when a file shares its name", () => + Effect.gen(function* () { + const baseDir = makeBaseDir(); + const cwdFile = NodePath.join(baseDir, "ocean"); + NodeFS.writeFileSync(cwdFile, NIGHTFALL_THEME_JSON); + + const previous = process.cwd(); + process.chdir(baseDir); + try { + yield* runCli(["theme", "set", "ocean", "--base-dir", baseDir]); + } finally { + process.chdir(previous); + } + + assert.equal(readSettings(baseDir).defaultTheme, "ocean"); + assert.equal( + NodeFS.existsSync(NodePath.join(baseDir, "userdata", "themes", "ocean.json")), + false, + ); + }), + ); + + // The watcher would skip an oversized file, so publishing one must not + // report success for a theme no client receives. + it.effect("rejects a theme file larger than the watcher will read", () => + Effect.gen(function* () { + const baseDir = makeBaseDir(); + const themeFile = NodePath.join(baseDir, "huge.json"); + const padding = "x".repeat(40 * 1024); + NodeFS.writeFileSync( + themeFile, + `{ "name": "Huge", "appearance": "dark", "canvas": "#1a1b26", "accent": "#7aa2f7", "note": "${padding}" }\n`, + ); + + const failure = yield* runCli(["theme", "set", themeFile, "--base-dir", baseDir]).pipe( + Effect.flip, + ); + assert.include(String(failure), "larger than"); + }), + ); + + it.effect("refuses a settings file that is not a JSON object", () => + Effect.gen(function* () { + const baseDir = makeBaseDir(); + NodeFS.mkdirSync(NodePath.dirname(settingsPathFor(baseDir)), { recursive: true }); + NodeFS.writeFileSync(settingsPathFor(baseDir), "[1, 2, 3]\n"); + + const failure = yield* runCli(["theme", "set", "ocean", "--base-dir", baseDir]).pipe( + Effect.flip, + ); + + assert.include(String(failure), "not a JSON object"); + }), + ); +}); diff --git a/apps/server/src/cli/theme.ts b/apps/server/src/cli/theme.ts new file mode 100644 index 000000000000..a54e6c37f392 --- /dev/null +++ b/apps/server/src/cli/theme.ts @@ -0,0 +1,587 @@ +// @effect-diagnostics nodeBuiltinImport:off - publish commits and rollbacks +// move exact directory entries with rename, which the FileSystem service does +// not expose atomically. +/** + * `t3 theme` - inspect and set the environment's theme. Connected web and + * desktop clients switch when it is set; mobile keeps its own appearance + * settings. Each client applies one set once, so a theme the user picks in + * Settings afterwards sticks until the next `t3 theme set`. + * + * Writes `defaultTheme` (and `defaultThemeSetAt`, so a re-set of the same + * value still acts) into the environment's `settings.json`. A running server + * watches that file and pushes the change, so this works before the first + * launch and on a live server alike. + * + * The edit is deliberately a minimal one on the parsed JSON object rather than + * a schema round-trip. Settings files outlive the build that reads them, and a + * provisioning command must not drop keys this version does not recognise. + */ +import * as NodeFS from "node:fs"; + +import { + EnvironmentThemeFile, + EnvironmentThemeId, + environmentThemeFileHasColors, +} from "@t3tools/contracts"; +import { fromJsonStringPretty, fromLenientJson } from "@t3tools/shared/schemaJson"; +import { BUILT_IN_THEME_IDS, UNPUBLISHABLE_THEME_IDS } from "@t3tools/shared/themePalettes"; +import * as Config from "effect/Config"; +import * as Console from "effect/Console"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import { Argument, Command, Flag } from "effect/unstable/cli"; + +import { writeFileStringAtomically } from "../atomicWrite.ts"; +import * as ServerConfig from "../config.ts"; +import { + MAX_THEME_FILE_BYTES, + readPublishedThemes, + readThemeFileGuarded, +} from "../environmentTheme.ts"; +import { expandHomePath, resolveBaseDir } from "../os-jank.ts"; +import { baseDirFlag } from "./config.ts"; + +/** Settings files outlive the build that reads them, so the object is carried + * as-is and only the theme keys are touched. */ +const SparseSettings = Schema.Record(Schema.String, Schema.Unknown); +const decodeSettingsJson = Schema.decodeUnknownEffect(fromLenientJson(SparseSettings)); +const encodeSettingsJson = Schema.encodeEffect(fromJsonStringPretty(SparseSettings)); +const decodeThemeFileJsonExit = Schema.decodeUnknownExit( + Schema.fromJsonString(EnvironmentThemeFile), +); +const isEnvironmentThemeId = Schema.is(EnvironmentThemeId); + +export class ThemeSettingsUnreadableError extends Schema.TaggedErrorClass()( + "ThemeSettingsUnreadableError", + { settingsPath: Schema.String, cause: Schema.Defect() }, +) { + override get message(): string { + return `Could not read ${this.settingsPath}. Fix its permissions, then run this again.`; + } +} + +export class ThemeSettingsMalformedError extends Schema.TaggedErrorClass()( + "ThemeSettingsMalformedError", + { settingsPath: Schema.String, cause: Schema.Defect() }, +) { + override get message(): string { + return `${this.settingsPath} is not a JSON object. Fix or remove it, then run this again.`; + } +} + +export class ThemeSettingsBusyError extends Schema.TaggedErrorClass()( + "ThemeSettingsBusyError", + { settingsPath: Schema.String, attempts: Schema.Number }, +) { + override get message(): string { + return `${this.settingsPath} kept changing while writing (gave up after ${this.attempts} attempts). Try again.`; + } +} + +export class ThemeSettingsWriteError extends Schema.TaggedErrorClass()( + "ThemeSettingsWriteError", + { settingsPath: Schema.String, cause: Schema.Defect() }, +) { + override get message(): string { + return `Could not write ${this.settingsPath}.`; + } +} + +export class ThemeFileUnreadableError extends Schema.TaggedErrorClass()( + "ThemeFileUnreadableError", + // Optional: a path that never existed has no underlying failure to carry, + // and a manufactured string there would only look like a real one. + { filePath: Schema.String, cause: Schema.optional(Schema.Defect()) }, +) { + override get message(): string { + return `Could not read ${this.filePath}.`; + } +} + +export class ThemeFileInvalidError extends Schema.TaggedErrorClass()( + "ThemeFileInvalidError", + { filePath: Schema.String, cause: Schema.Defect() }, +) { + override get message(): string { + return `${this.filePath} is not a valid theme file. Use a theme exported from T3 Code, or a seeded file with name, appearance, canvas, and accent.`; + } +} + +export class ThemeFileTooLargeError extends Schema.TaggedErrorClass()( + "ThemeFileTooLargeError", + { filePath: Schema.String, limit: Schema.Number }, +) { + override get message(): string { + return `${this.filePath} is larger than ${this.limit} bytes, which is more than a theme can publish.`; + } +} + +export class ThemeFileColorlessError extends Schema.TaggedErrorClass()( + "ThemeFileColorlessError", + { filePath: Schema.String }, +) { + override get message(): string { + return `${this.filePath} has no colors to publish.`; + } +} + +export class ThemePublishError extends Schema.TaggedErrorClass()( + "ThemePublishError", + { themesDir: Schema.String, cause: Schema.Defect() }, +) { + override get message(): string { + return `Could not publish the theme into ${this.themesDir}.`; + } +} + +const INVALID_THEME_ID_REASON = + "is not a valid theme id (lowercase letters, digits, and hyphens; not an appearance keyword)"; + +export class ThemeIdUnknownError extends Schema.TaggedErrorClass()( + "ThemeIdUnknownError", + { themeId: Schema.String, known: Schema.Array(Schema.String) }, +) { + override get message(): string { + return `No theme named "${this.themeId}". Available: ${this.known.join(", ")}. Publish one by passing a theme file instead of an id.`; + } +} + +export class ThemeIdInvalidError extends Schema.TaggedErrorClass()( + "ThemeIdInvalidError", + { themeId: Schema.String }, +) { + override get message(): string { + return `"${this.themeId}" ${INVALID_THEME_ID_REASON}.`; + } +} + +/** A filename that cannot be a theme id, where --id is the way out. */ +export class ThemeFileIdInvalidError extends Schema.TaggedErrorClass()( + "ThemeFileIdInvalidError", + { themeId: Schema.String, filePath: Schema.String }, +) { + override get message(): string { + return `"${this.themeId}" ${INVALID_THEME_ID_REASON}. Pass one with --id.`; + } +} + +export class ThemeTargetMissingError extends Schema.TaggedErrorClass()( + "ThemeTargetMissingError", + {}, +) { + override get message(): string { + return "Provide a theme id or file, or run `t3 theme clear` to remove the theme."; + } +} + +const envT3Home = Config.string("T3CODE_HOME").pipe(Config.option); + +const resolveThemePaths = Effect.fn(function* (explicitBaseDir: Option.Option) { + // Same precedence as the rest of the CLI: --base-dir, then T3CODE_HOME, + // then the default home. A provisioning script exporting T3CODE_HOME must + // not have this one command silently target the default install. + const envHome = Option.filter(yield* envT3Home, (value) => value.trim().length > 0); + const configuredBaseDir = Option.orElse(explicitBaseDir, () => envHome); + const baseDir = yield* resolveBaseDir(Option.getOrUndefined(configuredBaseDir)); + const derivedPaths = yield* ServerConfig.deriveServerPaths(baseDir, undefined, { + baseDirIsExplicit: Option.isSome(configuredBaseDir), + }); + return { + settingsPath: derivedPaths.settingsPath, + themesDir: derivedPaths.environmentThemesDir, + }; +}); + +/** + * Reads the sparse settings object, treating only a genuinely absent file as + * empty. A permission or I/O error must propagate: reading it as "no settings" + * would have the caller write a fresh sparse file over settings it never saw. + */ +const readSettingsObject = Effect.fn(function* (settingsPath: string) { + const fs = yield* FileSystem.FileSystem; + const exists = yield* fs + .exists(settingsPath) + .pipe(Effect.mapError((cause) => new ThemeSettingsUnreadableError({ settingsPath, cause }))); + if (!exists) return { raw: "", settings: {} }; + + const raw = yield* fs + .readFileString(settingsPath) + .pipe(Effect.mapError((cause) => new ThemeSettingsUnreadableError({ settingsPath, cause }))); + if (raw.trim().length === 0) return { raw, settings: {} }; + + const settings = yield* decodeSettingsJson(raw).pipe( + Effect.mapError((cause) => new ThemeSettingsMalformedError({ settingsPath, cause })), + ); + return { raw, settings }; +}); + +/** + * A running server owns this file too, and its write path is an in-process + * semaphore that cannot serialize against another process. So the document is + * re-read immediately before the rename and the whole edit is retried when it + * moved underneath us, which is what turns "last writer wins" into "last + * writer merges", and an edit that keeps losing the race fails loudly rather + * than overwriting. A write landing inside the remaining rename window is + * still possible; the server's own watcher reconciles the file either way. + */ +const CONCURRENT_WRITE_ATTEMPTS = 5; + +const writeDefaultTheme = Effect.fn(function* (input: { + readonly settingsPath: string; + readonly themeId: string; +}) { + const fs = yield* FileSystem.FileSystem; + + for (let attempt = 1; ; attempt++) { + const { raw, settings } = yield* readSettingsObject(input.settingsPath); + const setAt = DateTime.formatIso(yield* DateTime.now); + const next = + input.themeId.length > 0 + ? // The timestamp is the set-generation: it lets clients apply a re-set + // of the same value they already applied once. + { ...settings, defaultTheme: input.themeId, defaultThemeSetAt: setAt } + : // Clearing removes the keys rather than storing empty strings, so the + // file reads the same as one that never set a theme. + Object.fromEntries( + Object.entries(settings).filter( + ([key]) => key !== "defaultTheme" && key !== "defaultThemeSetAt", + ), + ); + + const contents = yield* encodeSettingsJson(next); + const current = yield* fs + .readFileString(input.settingsPath) + .pipe(Effect.orElseSucceed(() => "")); + if (current !== raw) { + // Falling through here would overwrite whatever landed in between, which + // is exactly the loss this loop exists to prevent. + if (attempt >= CONCURRENT_WRITE_ATTEMPTS) { + return yield* Effect.fail( + new ThemeSettingsBusyError({ + settingsPath: input.settingsPath, + attempts: CONCURRENT_WRITE_ATTEMPTS, + }), + ); + } + continue; + } + + yield* writeFileStringAtomically({ + filePath: input.settingsPath, + contents: `${contents}\n`, + }).pipe( + Effect.mapError( + (cause) => new ThemeSettingsWriteError({ settingsPath: input.settingsPath, cause }), + ), + ); + return; + } +}); + +/** Publishes a theme file into the environment's themes directory and returns + * the id it published under. */ +const publishThemeFile = Effect.fn(function* (input: { + readonly themesDir: string; + readonly filePath: string; + readonly explicitId: Option.Option; +}) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + // A preflight for error quality only: it tells a FIFO from an oversized + // file. Enforcement happens at the guarded read below. + const info = yield* fs + .stat(input.filePath) + .pipe( + Effect.mapError((cause) => new ThemeFileUnreadableError({ filePath: input.filePath, cause })), + ); + if (info.type !== "File") { + return yield* Effect.fail(new ThemeFileUnreadableError({ filePath: input.filePath })); + } + if (Number(info.size) > MAX_THEME_FILE_BYTES) { + return yield* Effect.fail( + new ThemeFileTooLargeError({ filePath: input.filePath, limit: MAX_THEME_FILE_BYTES }), + ); + } + + // An explicit source path is the user's own input, and a symlink there is a + // normal way to point at a theme (desktop hooks symlink the current + // palette), so it is resolved before the guarded read. The read still goes + // through one opened handle whose type and size checks bind to the file + // actually read, so a FIFO cannot hang the command and an oversized target + // is refused. + const resolvedSource = yield* fs + .realPath(input.filePath) + .pipe( + Effect.mapError((cause) => new ThemeFileUnreadableError({ filePath: input.filePath, cause })), + ); + const raw = readThemeFileGuarded(resolvedSource, MAX_THEME_FILE_BYTES); + if (raw === null) { + return yield* Effect.fail(new ThemeFileUnreadableError({ filePath: input.filePath })); + } + + const decoded = decodeThemeFileJsonExit(raw); + if (decoded._tag === "Failure") { + return yield* Effect.fail( + new ThemeFileInvalidError({ filePath: input.filePath, cause: decoded.cause }), + ); + } + if (!environmentThemeFileHasColors(decoded.value)) { + return yield* Effect.fail(new ThemeFileColorlessError({ filePath: input.filePath })); + } + + const fileBasename = path.basename(input.filePath, ".json"); + const themeId = Option.getOrElse(input.explicitId, () => fileBasename); + // The same rules the watcher applies when it reads the directory back, so a + // publish cannot report success for a file that will then be skipped. + if (!isEnvironmentThemeId(themeId) || UNPUBLISHABLE_THEME_IDS.has(themeId)) { + return yield* Effect.fail(new ThemeFileIdInvalidError({ themeId, filePath: input.filePath })); + } + + const destinationPath = path.join(input.themesDir, `${themeId}.json`); + // Neither ends in `.json`, so the watcher never mistakes them for themes. + // Both names carry the pid, so concurrent publishers of one id cannot + // unlink or restore over each other's staging and rollback copies. + const backupPath = `${destinationPath}.rollback-${process.pid}`; + const stagingPath = `${destinationPath}.staging-${process.pid}`; + yield* fs + .makeDirectory(input.themesDir, { recursive: true }) + .pipe(Effect.mapError((cause) => new ThemePublishError({ themesDir: input.themesDir, cause }))); + + const publishFailure = (cause: unknown) => + new ThemePublishError({ themesDir: input.themesDir, cause }); + + // Staged in full before anything moves, so the commit below is two adjacent + // renames with no I/O between them. The staging entry is created O_EXCL + // after clearing any stale leftover, so a symlink or file already at that + // predictable name is never followed or written through. Written verbatim: + // appending so much as a newline could push a file at the size limit past + // it and have the watcher skip what was just accepted. + const stagedIno = yield* Effect.try({ + try: () => { + try { + NodeFS.unlinkSync(stagingPath); + } catch { + // Nothing stale to clear. + } + const fd = NodeFS.openSync( + stagingPath, + NodeFS.constants.O_WRONLY | NodeFS.constants.O_CREAT | NodeFS.constants.O_EXCL, + 0o644, + ); + try { + NodeFS.writeFileSync(fd, raw); + // Rename preserves the inode, so this identifies our published file + // at the destination for as long as it is actually ours. + return NodeFS.fstatSync(fd).ino; + } finally { + NodeFS.closeSync(fd); + } + }, + catch: publishFailure, + }); + + // Whatever occupies the destination -- a theme, a symlink, anything -- is + // moved aside in one atomic step rather than inspected and then replaced: + // there is no window between a check and the commit, and rollback restores + // that exact directory entry instead of a re-read of it. Only "nothing + // there" continues; any other rename failure aborts before the destination + // is touched. + const hadPrevious = yield* Effect.try({ + try: () => { + try { + NodeFS.renameSync(destinationPath, backupPath); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; + throw error; + } + }, + catch: publishFailure, + }); + + const revert = Effect.sync(() => { + try { + NodeFS.unlinkSync(stagingPath); + } catch { + // Usually already renamed away; a stray staging file is watcher-inert. + } + try { + // The destination is touched only while it is empty or still holds + // the exact file this process put there; a concurrent publisher's + // newer file wins, and this process's obsolete copy is discarded. + const destinationIno = (() => { + try { + return NodeFS.lstatSync(destinationPath).ino; + } catch { + return null; + } + })(); + if (hadPrevious) { + if (destinationIno === null || destinationIno === stagedIno) { + NodeFS.renameSync(backupPath, destinationPath); + } else { + NodeFS.unlinkSync(backupPath); + } + } else if (destinationIno === stagedIno) { + NodeFS.unlinkSync(destinationPath); + } + } catch { + // Best effort; the failure that triggered the revert still surfaces. + } + }); + const cleanup = Effect.sync(() => { + try { + if (hadPrevious) NodeFS.unlinkSync(backupPath); + } catch { + // A stray backup is inert: it is not `.json`, so nothing serves it. + } + }); + + yield* Effect.try({ + try: () => NodeFS.renameSync(stagingPath, destinationPath), + catch: publishFailure, + }).pipe(Effect.onError(() => revert)); + + return { themeId, revert, cleanup }; +}); + +/** + * Ids a client can actually resolve: this build's built-ins plus what the + * machine publishes, read through the same function the watcher uses so a file + * it would skip can never be accepted here. The mobile default is absent on + * purpose -- web and desktop cannot resolve it and mobile does not follow this + * setting, so naming it would be the silent no-op this check exists to stop. + */ +const resolvableThemeIds = Effect.fn(function* (themesDir: string) { + const published = yield* readPublishedThemes(themesDir); + return [...BUILT_IN_THEME_IDS, ...published.map((theme) => theme.id)].toSorted(); +}); + +const themeSetCommand = Command.make("set", { + baseDir: baseDirFlag, + id: Flag.string("id").pipe( + Flag.withDescription("Theme id to publish a file under, instead of its filename."), + Flag.optional, + ), + theme: Argument.string("theme").pipe( + Argument.withDescription( + 'A theme id (a built-in, or one this machine publishes — themes/nightfall.json is "nightfall"), or a path to a theme JSON file to publish and set in one step.', + ), + ), +}).pipe( + Command.withDescription("Set the environment's theme; connected clients switch to it."), + Command.withHandler((flags) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const target = yield* expandHomePath(flags.theme.trim()); + if (target.length === 0) { + return yield* Effect.fail(new ThemeTargetMissingError()); + } + const paths = yield* resolveThemePaths(flags.baseDir); + + // An existing file publishes; anything path-shaped that does not exist + // is a mistake to surface, not an id to store; everything else must be + // a well-formed id, so a typo cannot be written as a theme no client + // will ever resolve. + // Path-shaped first, existence second. Deciding on existence alone would + // make `t3 theme set ocean` publish ./ocean whenever the cwd happens to + // hold a file by that name, instead of selecting the built-in. + const looksLikePath = + target.endsWith(".json") || + target.includes("/") || + target.includes("\\") || + target.startsWith("~"); + const targetIsFile = + looksLikePath && (yield* fs.exists(target).pipe(Effect.orElseSucceed(() => false))); + let themeId: string; + let revertPublish: Effect.Effect = Effect.void; + let cleanupPublish: Effect.Effect = Effect.void; + if (targetIsFile) { + // Settings are preflighted before publishing, so a settings file the + // set step cannot read or parse fails the command before it mutates + // the themes directory. + yield* readSettingsObject(paths.settingsPath); + const published = yield* publishThemeFile({ + themesDir: paths.themesDir, + filePath: target, + explicitId: flags.id, + }); + themeId = published.themeId; + revertPublish = published.revert; + cleanupPublish = published.cleanup; + } else if (looksLikePath) { + return yield* Effect.fail(new ThemeFileUnreadableError({ filePath: target })); + } else if (isEnvironmentThemeId(target)) { + const known = yield* resolvableThemeIds(paths.themesDir); + if (!known.includes(target)) { + return yield* Effect.fail(new ThemeIdUnknownError({ themeId: target, known })); + } + themeId = target; + } else { + return yield* Effect.fail(new ThemeIdInvalidError({ themeId: target })); + } + + // set means set: if the default cannot be written, the publish that + // rode along with it is undone rather than left as a side effect of a + // command that reported failure. + yield* writeDefaultTheme({ settingsPath: paths.settingsPath, themeId }).pipe( + Effect.onError(() => revertPublish), + ); + yield* cleanupPublish; + yield* Console.log( + targetIsFile + ? `Published ${target} as "${themeId}" and set it as the environment theme.\n` + : `Environment theme set to "${themeId}" in ${paths.settingsPath}.\n`, + ); + }), + ), +); + +const themeClearCommand = Command.make("clear", { baseDir: baseDirFlag }).pipe( + Command.withDescription("Remove the environment's theme; clients keep what they have."), + Command.withHandler((flags) => + Effect.gen(function* () { + const paths = yield* resolveThemePaths(flags.baseDir); + yield* writeDefaultTheme({ settingsPath: paths.settingsPath, themeId: "" }); + yield* Console.log(`Environment theme cleared in ${paths.settingsPath}.\n`); + }), + ), +); + +const themeShowCommand = Command.make("show", { baseDir: baseDirFlag }).pipe( + Command.withDescription("Show the environment's theme and its published themes."), + Command.withHandler((flags) => + Effect.gen(function* () { + const paths = yield* resolveThemePaths(flags.baseDir); + const { settings } = yield* readSettingsObject(paths.settingsPath); + const defaultTheme = + typeof settings.defaultTheme === "string" && settings.defaultTheme.length > 0 + ? settings.defaultTheme + : null; + + const published = (yield* readPublishedThemes(paths.themesDir)) + .map((theme) => theme.id) + .toSorted(); + + yield* Console.log( + defaultTheme === null + ? "Environment theme: not set.\n" + : `Environment theme: "${defaultTheme}".\n`, + ); + yield* Console.log( + published.length === 0 + ? `Published themes: none (publish into ${paths.themesDir}).\n` + : `Published themes: ${published.join(", ")}.\n`, + ); + }), + ), +); + +export const themeCommand = Command.make("theme").pipe( + Command.withDescription("Inspect and set environment-wide theme defaults."), + Command.withSubcommands([themeSetCommand, themeClearCommand, themeShowCommand]), +); diff --git a/apps/server/src/cli/triage.ts b/apps/server/src/cli/triage.ts index 76d577a12c3f..bcf5970310fd 100644 --- a/apps/server/src/cli/triage.ts +++ b/apps/server/src/cli/triage.ts @@ -29,7 +29,7 @@ import { Command, Flag } from "effect/unstable/cli"; import packageJson from "../../package.json" with { type: "json" }; import * as ServerConfig from "../config.ts"; import { resolveBaseDir } from "../os-jank.ts"; -import { readPersistedServerRuntimeState } from "../serverRuntimeState.ts"; +import { isProcessAlive, readPersistedServerRuntimeState } from "../serverRuntimeState.ts"; import { baseDirFlag } from "./config.ts"; import { resolveCliCommand } from "./invocation.ts"; import { @@ -76,17 +76,6 @@ export class TriageAgentSpawnError extends Schema.TaggedErrorClass { - try { - process.kill(pid, 0); - return true; - } catch (error) { - return error instanceof Error && "code" in error && error.code === "EPERM"; - } -}; - /** One human-readable line about the local server, for `context.md`. */ const describeServerProcess = Effect.fn("triage.describeServerProcess")(function* ( serverRuntimeStatePath: string, diff --git a/apps/server/src/cloud/bootService.test.ts b/apps/server/src/cloud/bootService.test.ts index 1314ccfb9361..a999f81b2898 100644 --- a/apps/server/src/cloud/bootService.test.ts +++ b/apps/server/src/cloud/bootService.test.ts @@ -56,17 +56,26 @@ const macPlan = { logPath: "/Users/theo/.t3/userdata/logs/boot-service.log", unitPath: "/Users/theo/Library/LaunchAgents/com.t3tools.t3code.service.plist", }; +const macInstallerPath = + "/opt/homebrew/bin:/Users/theo/.npm-global/bin:/Users/theo/.nvm/versions/node/v22.16.0/bin:/usr/bin:/bin"; +const macRenderOptions = { homeDir: "/Users/theo", environmentPath: macInstallerPath }; it("keeps launchd pinned to the stable launcher rather than a versioned server", () => { - const plist = BootService.renderBootServicePlist(macPlan, { homeDir: "/Users/theo" }); + const plist = BootService.renderBootServicePlist(macPlan, macRenderOptions); expect(plist).toContain("/opt/homebrew/bin/node"); expect(plist).toContain("/Users/theo/.t3/runtime/service-launcher.mjs"); expect(plist).not.toContain("versions/1.2.3"); }); +it("preserves the installer's provider search path in the launch agent", () => { + const plist = BootService.renderBootServicePlist(macPlan, macRenderOptions); + + expect(plist).toContain(` PATH\n ${macInstallerPath}`); +}); + it("restarts the launch agent on the systemd cadence", () => { - const plist = BootService.renderBootServicePlist(macPlan, { homeDir: "/Users/theo" }); + const plist = BootService.renderBootServicePlist(macPlan, macRenderOptions); expect(plist).toContain("RunAtLoad\n "); expect(plist).toContain("KeepAlive\n "); @@ -75,7 +84,7 @@ it("restarts the launch agent on the systemd cadence", () => { }); it("appends both stdio streams to the boot service log", () => { - const plist = BootService.renderBootServicePlist(macPlan, { homeDir: "/Users/theo" }); + const plist = BootService.renderBootServicePlist(macPlan, macRenderOptions); expect(plist).toContain( "StandardOutPath\n /Users/theo/.t3/userdata/logs/boot-service.log", @@ -88,15 +97,17 @@ it("appends both stdio streams to the boot service log", () => { it("escapes XML in host paths", () => { const plist = BootService.renderBootServicePlist( { ...macPlan, baseDir: "/Users/theo/T3 & " }, - { homeDir: "/Users/theo" }, + { homeDir: "/Users/theo", environmentPath: "/Users/theo/Tools & :/usr/bin" }, ); expect(plist).toContain("/Users/theo/T3 & <Co>"); + expect(plist).toContain("/Users/theo/Tools & <Scripts>:/usr/bin"); }); const makeHarness = Effect.fn("test.make_boot_service_harness")(function* ( platform: NodeJS.Platform = "linux", usePinnedLauncher = false, + installerPath = macInstallerPath, ) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -135,27 +146,33 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* ( }; }), }); - const service = yield* BootService.make({ - baseDir, - logsDir: path.join(baseDir, "userdata", "logs"), - cliVersion: "1.2.3", - host: { - execPath: "/usr/bin/node", - ...(usePinnedLauncher ? {} : { launcherSourcePath: sourceLauncher }), - }, - }).pipe( - Effect.provideService(ProcessRunner.ProcessRunner, runner), - Effect.provide( - Layer.mergeAll( - Layer.succeed(HostProcessPlatform, platform), - Layer.succeed(HostProcessUserId, 501), - Layer.succeed(HostProcessExecutablePath, "/usr/bin/node"), - Layer.succeed(HostProcessArguments, ["/usr/bin/node", path.join(home, "bin.mjs")]), - ConfigProvider.layer(ConfigProvider.fromEnv({ env: { HOME: home } })), + const makeService = (environmentPath = installerPath) => + BootService.make({ + baseDir, + logsDir: path.join(baseDir, "userdata", "logs"), + cliVersion: "1.2.3", + host: { + execPath: "/usr/bin/node", + ...(usePinnedLauncher ? {} : { launcherSourcePath: sourceLauncher }), + }, + }).pipe( + Effect.provideService(ProcessRunner.ProcessRunner, runner), + Effect.provide( + Layer.mergeAll( + Layer.succeed(HostProcessPlatform, platform), + Layer.succeed(HostProcessUserId, 501), + Layer.succeed(HostProcessExecutablePath, "/usr/bin/node"), + Layer.succeed(HostProcessArguments, ["/usr/bin/node", path.join(home, "bin.mjs")]), + ConfigProvider.layer( + ConfigProvider.fromEnv({ + env: { HOME: home, ...(environmentPath === "" ? {} : { PATH: environmentPath }) }, + }), + ), + ), ), - ), - ); - return { service, fs, statePath, commands, timeouts, control }; + ); + const service = yield* makeService(); + return { service, makeService, fs, statePath, commands, timeouts, control }; }); it.layer(NodeServices.layer)("boot service install", (it) => { @@ -266,6 +283,9 @@ it.layer(NodeServices.layer)("boot service install", (it) => { expect(plan.unitPath.endsWith("Library/LaunchAgents/com.t3tools.t3code.service.plist")).toBe( true, ); + expect(yield* fs.readFileString(plan.unitPath)).toContain( + ` PATH\n ${macInstallerPath}:/usr/local/bin:/usr/sbin:/sbin`, + ); expect(parseServiceState(yield* fs.readFileString(statePath))).toEqual({ protocol: SERVICE_LAUNCHER_PROTOCOL, activeVersion: "1.2.3", @@ -303,6 +323,58 @@ it.layer(NodeServices.layer)("boot service install", (it) => { }), ); + it.effect("reconstructs a launch agent search path when the installer has no PATH", () => + Effect.gen(function* () { + const { service, fs } = yield* makeHarness("darwin", false, ""); + const plan = yield* service.install; + + expect(yield* fs.readFileString(plan.unitPath)).toContain( + " PATH\n /usr/bin:/opt/homebrew/bin:/usr/local/bin:/bin:/usr/sbin:/sbin", + ); + expect((yield* service.status).current).toBe(true); + }), + ); + + it.effect("adds missing provider directories to a minimal installer PATH", () => + Effect.gen(function* () { + const { service, fs } = yield* makeHarness("darwin", false, "/usr/bin:/bin"); + const plan = yield* service.install; + + expect(yield* fs.readFileString(plan.unitPath)).toContain( + " PATH\n /usr/bin:/bin:/opt/homebrew/bin:/usr/local/bin:/usr/sbin:/sbin", + ); + expect((yield* service.status).current).toBe(true); + }), + ); + + it.effect("keeps an installed launch agent current when the process PATH changes", () => + Effect.gen(function* () { + const { service, makeService } = yield* makeHarness("darwin"); + yield* service.install; + + const restartedService = yield* makeService("/usr/local/bin:/usr/bin:/bin"); + expect((yield* restartedService.status).current).toBe(true); + }), + ); + + it.effect("drops PATH directories that cannot be represented in a launch agent plist", () => + Effect.gen(function* () { + const { service, fs } = yield* makeHarness( + "darwin", + false, + "/opt/homebrew/bin:/Users/theo/\u0001invalid:/usr/bin", + ); + const plan = yield* service.install; + const plist = yield* fs.readFileString(plan.unitPath); + + expect(plist).toContain( + " PATH\n /opt/homebrew/bin:/usr/bin:/usr/local/bin:/bin:/usr/sbin:/sbin", + ); + expect(plist).not.toContain("\u0001"); + expect((yield* service.status).current).toBe(true); + }), + ); + it.effect("ignores a bootout for an agent that is not loaded", () => Effect.gen(function* () { const { service, control } = yield* makeHarness("darwin"); diff --git a/apps/server/src/cloud/bootService.ts b/apps/server/src/cloud/bootService.ts index 795bf38e979d..969f45e67802 100644 --- a/apps/server/src/cloud/bootService.ts +++ b/apps/server/src/cloud/bootService.ts @@ -30,19 +30,19 @@ import { } from "./serviceProtocol.ts"; const BOOT_SERVICE_NAME = "t3code"; -export const BOOT_SERVICE_UNIT_FILE = `${BOOT_SERVICE_NAME}.service`; +const BOOT_SERVICE_UNIT_FILE = `${BOOT_SERVICE_NAME}.service`; // `.service` suffix keeps the label distinct from the desktop app's bundle id // (com.t3tools.t3code), so launchd and TCC records never collide. -export const BOOT_SERVICE_LAUNCHD_LABEL = "com.t3tools.t3code.service"; -export const BOOT_SERVICE_PLIST_FILE = `${BOOT_SERVICE_LAUNCHD_LABEL}.plist`; -export const BOOT_SERVICE_UNIT_ENV = "T3_BOOT_SERVICE_UNIT"; +const BOOT_SERVICE_LAUNCHD_LABEL = "com.t3tools.t3code.service"; +const BOOT_SERVICE_PLIST_FILE = `${BOOT_SERVICE_LAUNCHD_LABEL}.plist`; +const BOOT_SERVICE_UNIT_ENV = "T3_BOOT_SERVICE_UNIT"; /** systemd expands `%` specifiers, including in unquoted append-log paths. */ -export function escapeSystemdSpecifiers(value: string): string { +function escapeSystemdSpecifiers(value: string): string { return value.replaceAll("%", "%%"); } -export function quoteSystemdValue(value: string): string { +function quoteSystemdValue(value: string): string { const escaped = escapeSystemdSpecifiers(value); return /[\s"'\\]/.test(escaped) ? `"${escaped.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"` @@ -92,14 +92,14 @@ export function renderBootServiceUnit(plan: BootServicePlan): string { } /** Plist values are emitted as XML text nodes; only these three need escaping. */ -export function escapeXmlText(value: string): string { +function escapeXmlText(value: string): string { return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">"); } /** Pure renderer: launch agents cannot rely on the user's shell or PATH. */ export function renderBootServicePlist( plan: BootServicePlan, - options: { readonly homeDir: string }, + options: { readonly homeDir: string; readonly environmentPath: string }, ): string { // KeepAlive + ThrottleInterval mirror Restart=always + RestartSec=5. launchd // has no StartLimitBurst analog; a hard crash loop respawns every 5s forever. @@ -127,6 +127,8 @@ export function renderBootServicePlist( ` `, ` EnvironmentVariables`, ` `, + ` PATH`, + ` ${escapeXmlText(options.environmentPath)}`, ` T3CODE_HOME`, ` ${escapeXmlText(plan.baseDir)}`, ` ${BOOT_SERVICE_UNIT_ENV}`, @@ -197,7 +199,7 @@ export interface BootServiceManager { readonly finalize: ReadonlyArray; } -export function systemdManager(input: { +function systemdManager(input: { readonly path: Path.Path; readonly homeDir: string; }): BootServiceManager { @@ -264,10 +266,11 @@ export function systemdManager(input: { }; } -export function launchdManager(input: { +function launchdManager(input: { readonly path: Path.Path; readonly homeDir: string; readonly uid: number; + readonly environmentPath: string; }): BootServiceManager { const unitPath = input.path.join( input.homeDir, @@ -287,7 +290,11 @@ export function launchdManager(input: { return { kind: "launchd", unitPath, - render: (plan) => renderBootServicePlist(plan, { homeDir: input.homeDir }), + render: (plan) => + renderBootServicePlist(plan, { + homeDir: input.homeDir, + environmentPath: input.environmentPath, + }), // Without --wait, bootout returns in milliseconds while the job drains // for up to ExitTimeOut, and a bootstrap during the drain fails EIO. // --wait (present on modern macOS, absent from the man page) blocks until @@ -341,11 +348,12 @@ export function launchdManager(input: { } /** Undefined means this host cannot run the background service. */ -export function selectBootServiceManager(input: { +function selectBootServiceManager(input: { readonly platform: NodeJS.Platform; readonly homeDir: string; readonly uid: number | undefined; readonly path: Path.Path; + readonly environmentPath: string; }): BootServiceManager | undefined { if (input.homeDir === "") { return undefined; @@ -354,7 +362,12 @@ export function selectBootServiceManager(input: { return systemdManager({ path: input.path, homeDir: input.homeDir }); } if (input.platform === "darwin" && input.uid !== undefined) { - return launchdManager({ path: input.path, homeDir: input.homeDir, uid: input.uid }); + return launchdManager({ + path: input.path, + homeDir: input.homeDir, + uid: input.uid, + environmentPath: input.environmentPath, + }); } return undefined; } @@ -441,12 +454,39 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { const platform = yield* HostProcessPlatform; const uid = yield* HostProcessUserId; const homeDir = yield* Config.string("HOME").pipe(Config.withDefault("")); + const installerPath = yield* Config.string("PATH").pipe(Config.withDefault("")); const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const runner = yield* ProcessRunner.ProcessRunner; const host = input.host ?? { execPath: hostExecPath }; - - const detectedManager = selectBootServiceManager({ platform, homeDir, uid, path }); + const xmlSafeInstallerDirectories = installerPath.split(":").filter( + (directory) => + directory.length > 0 && + Array.from(directory).every((character) => { + const code = character.charCodeAt(0); + return code >= 0x20 || code === 0x09 || code === 0x0a || code === 0x0d; + }), + ); + const environmentPath = Array.from( + new Set([ + ...xmlSafeInstallerDirectories, + path.dirname(host.execPath), + "/opt/homebrew/bin", + "/usr/local/bin", + "/usr/bin", + "/bin", + "/usr/sbin", + "/sbin", + ]), + ).join(":"); + + const detectedManager = selectBootServiceManager({ + platform, + homeDir, + uid, + path, + environmentPath, + }); const unitPath = detectedManager?.unitPath ?? ""; const logPath = path.join(input.logsDir, "boot-service.log"); const launcherPath = path.join(input.baseDir, "runtime", SERVICE_LAUNCHER_FILE); @@ -664,11 +704,15 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { fs.readFileString(statePath).pipe(Effect.option), ]); const state = Option.isSome(stateText) ? parseServiceState(stateText.value) : undefined; + const normalizeUnit = (contents: string) => + detectedManager.kind === "launchd" + ? contents.replace(/(PATH<\/key>\n\s*)[^<]*(<\/string>)/, "$1$2") + : contents; return { supported: true, installed: true, current: - unit === detectedManager.render(plan) && + normalizeUnit(unit) === normalizeUnit(detectedManager.render(plan)) && launcherExists && runtimeEntryExists && Option.isSome(runtimeSentinel) && diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index bdff19572fdd..42df3814b070 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -33,6 +33,8 @@ export interface ServerDerivedPaths { readonly dbPath: string; readonly keybindingsConfigPath: string; readonly settingsPath: string; + /** Palettes this machine publishes for clients to follow, one file per theme. */ + readonly environmentThemesDir: string; readonly providerStatusCacheDir: string; readonly worktreesDir: string; readonly attachmentsDir: string; @@ -119,6 +121,7 @@ export const deriveServerPaths = Effect.fn(function* ( dbPath, keybindingsConfigPath: join(stateDir, "keybindings.json"), settingsPath: join(stateDir, "settings.json"), + environmentThemesDir: join(stateDir, "themes"), providerStatusCacheDir, worktreesDir: join(baseDir, "worktrees"), attachmentsDir, diff --git a/apps/server/src/environment/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts index b9a50ca8335e..32006f691ed7 100644 --- a/apps/server/src/environment/ServerEnvironment.test.ts +++ b/apps/server/src/environment/ServerEnvironment.test.ts @@ -1,5 +1,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { expect, it } from "@effect/vitest"; +import * as Crypto from "effect/Crypto"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -71,6 +73,77 @@ const makeServerConfig = Effect.fn(function* (baseDir: string) { }); it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { + it.effect.each([ + { name: "missing", content: undefined }, + { name: "empty", content: "" }, + { name: "whitespace-only", content: " \t\n" }, + ])("concurrent initializers recover a $name environment id file", ({ content }) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const crypto = yield* Crypto.Crypto; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-server-environment-concurrent-test-", + }); + const serverConfig = yield* makeServerConfig(baseDir); + yield* fileSystem.makeDirectory(serverConfig.stateDir, { recursive: true }); + if (content !== undefined) { + yield* fileSystem.writeFileString(serverConfig.environmentIdPath, content); + } + const bothGenerated = yield* Deferred.make(); + const bothReadEmpty = yield* Deferred.make(); + const firstInitialized = yield* Deferred.make(); + let remaining = 2; + let emptyReads = 0; + const readIdentity = Effect.gen(function* () { + const identity = yield* ServerEnvironment.ServerEnvironmentIdentity; + return yield* identity.getEnvironmentId; + }).pipe( + Effect.tap(() => Deferred.succeed(firstInitialized, undefined)), + Effect.provide(Layer.fresh(ServerEnvironment.identityLayer)), + Effect.provideService(ServerConfig.ServerConfig, serverConfig), + Effect.provideService(FileSystem.FileSystem, { + ...fileSystem, + readFileString: (path) => + fileSystem.readFileString(path).pipe( + Effect.tap( + Effect.fn(function* (value) { + if (path !== serverConfig.environmentIdPath || remaining > 0 || value.trim()) { + return; + } + // Both observe the empty file, but one repairs it after the other has finished. + if (++emptyReads === 2) { + yield* Deferred.succeed(bothReadEmpty, undefined); + yield* Deferred.await(firstInitialized); + } else { + yield* Deferred.await(bothReadEmpty); + } + }), + ), + ), + }), + Effect.provideService(Crypto.Crypto, { + ...crypto, + randomUUIDv4: Effect.gen(function* () { + const id = yield* crypto.randomUUIDv4; + if (--remaining === 0) { + yield* Deferred.succeed(bothGenerated, undefined); + } + yield* Deferred.await(bothGenerated); + return id; + }), + }), + ); + + const [first, second] = yield* Effect.all([readIdentity, readIdentity], { + concurrency: "unbounded", + }); + const persisted = yield* fileSystem.readFileString(serverConfig.environmentIdPath); + + expect(first).toBe(second); + expect(persisted.trim()).toBe(first); + }), + ); + it.effect("persists the environment id across service restarts", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -91,8 +164,10 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { expect(second.capabilities.repositoryIdentity).toBe(true); expect(second.capabilities.connectionProbe).toBe(true); expect(second.capabilities.attachmentUploads).toBe(true); + expect(second.capabilities.fileAttachments).toEqual({ maxUploadBytes: 50 * 1024 * 1024 }); expect(second.capabilities.pullRequests).toBe(true); expect(second.capabilities.threadTitleRegeneration).toBe(true); + expect(second.capabilities.threadPullRequestLinking).toBe(true); expect(second.capabilities.agentActivityPublishing).toBe(false); }), ); @@ -151,6 +226,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { }); const serverConfig = yield* makeServerConfig(baseDir); const environmentIdPath = serverConfig.environmentIdPath; + const tempPath = `${environmentIdPath}.tmp`; const methodByOperation = { check: "exists", read: "readFileString", @@ -170,6 +246,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { exists: () => operation === "check" ? Effect.fail(cause) : Effect.succeed(operation === "read"), readFileString: () => Effect.fail(cause), + makeTempFileScoped: () => Effect.succeed(tempPath), writeFileString: (path) => { writeAttempts.push(path); return Effect.fail(cause); @@ -199,7 +276,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { expect(error.message).toBe( `Server environment ID ${operation} failed at '${environmentIdPath}'.`, ); - expect(writeAttempts).toEqual(operation === "write" ? [environmentIdPath] : []); + expect(writeAttempts).toEqual(operation === "write" ? [tempPath] : []); } }), ); diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index e55639ce659c..0de59db78160 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -1,4 +1,8 @@ -import { EnvironmentId, type ExecutionEnvironmentDescriptor } from "@t3tools/contracts"; +import { + EnvironmentId, + PROVIDER_SEND_TURN_MAX_FILE_BYTES, + type ExecutionEnvironmentDescriptor, +} from "@t3tools/contracts"; import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; @@ -20,12 +24,15 @@ import { resolveServerEnvironmentLabel } from "./ServerEnvironmentLabel.ts"; export class ServerEnvironmentIdPersistenceError extends Schema.TaggedErrorClass()( "ServerEnvironmentIdPersistenceError", { - operation: Schema.Literals(["check", "read", "write"]), + operation: Schema.Literals(["check", "read", "write", "initialize"]), environmentIdPath: Schema.String, - cause: Schema.Defect(), + cause: Schema.optional(Schema.Defect()), }, ) { override get message(): string { + if (this.operation === "initialize") { + return `Server environment ID file is missing or empty after initialization at '${this.environmentIdPath}'.`; + } return `Server environment ID ${this.operation} failed at '${this.environmentIdPath}'.`; } } @@ -38,6 +45,13 @@ export class ServerEnvironment extends Context.Service< } >()("t3/environment/ServerEnvironment") {} +export class ServerEnvironmentIdentity extends Context.Service< + ServerEnvironmentIdentity, + { + readonly getEnvironmentId: Effect.Effect; + } +>()("t3/environment/ServerEnvironment/ServerEnvironmentIdentity") {} + function platformOs(platform: NodeJS.Platform): ExecutionEnvironmentDescriptor["platform"]["os"] { switch (platform) { case "darwin": @@ -64,14 +78,10 @@ function platformArch( } } -export const make = Effect.gen(function* () { +const makeIdentity = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; const serverConfig = yield* ServerConfig.ServerConfig; - const secrets = yield* ServerSecretStore.ServerSecretStore; const crypto = yield* Crypto.Crypto; - const hostPlatform = yield* HostProcessPlatform; - const hostArchitecture = yield* HostProcessArchitecture; const readPersistedEnvironmentId = Effect.gen(function* () { const exists = yield* fileSystem.exists(serverConfig.environmentIdPath).pipe( @@ -103,17 +113,42 @@ export const make = Effect.gen(function* () { return raw.length > 0 ? raw : null; }); - const persistEnvironmentId = (value: string) => - fileSystem.writeFileString(serverConfig.environmentIdPath, `${value}\n`).pipe( - Effect.mapError( - (cause) => - new ServerEnvironmentIdPersistenceError({ - operation: "write", - environmentIdPath: serverConfig.environmentIdPath, - cause, - }), - ), - ); + const persistEnvironmentId = Effect.fn("ServerEnvironmentIdentity.persistEnvironmentId")( + function* (value: string, mode: "create" | "recover") { + const destinationPath = + mode === "recover" + ? `${serverConfig.environmentIdPath}.recovery` + : serverConfig.environmentIdPath; + const tempPath = yield* fileSystem.makeTempFileScoped({ + directory: serverConfig.stateDir, + prefix: ".environment-id-", + }); + yield* fileSystem.writeFileString(tempPath, `${value}\n`); + // Publish the completed file without replacing an ID created by another process. + yield* fileSystem + .link(tempPath, destinationPath) + .pipe( + Effect.catch((cause) => + cause.reason._tag === "AlreadyExists" ? Effect.void : Effect.fail(cause), + ), + ); + if (mode === "recover") { + // Keep the recovery ID so delayed initializers also publish the same winner. + yield* fileSystem.remove(tempPath); + yield* fileSystem.copyFile(destinationPath, tempPath); + yield* fileSystem.rename(tempPath, serverConfig.environmentIdPath); + } + }, + Effect.scoped, + Effect.mapError( + (cause) => + new ServerEnvironmentIdPersistenceError({ + operation: "write", + environmentIdPath: serverConfig.environmentIdPath, + cause, + }), + ), + ); const environmentIdRaw = yield* Effect.gen(function* () { const persisted = yield* readPersistedEnvironmentId; @@ -122,11 +157,35 @@ export const make = Effect.gen(function* () { } const generated = yield* crypto.randomUUIDv4; - yield* persistEnvironmentId(generated); - return generated; + yield* persistEnvironmentId(generated, "create"); + let winner = yield* readPersistedEnvironmentId; + if (winner === null) { + yield* persistEnvironmentId(generated, "recover"); + winner = yield* readPersistedEnvironmentId; + } + if (winner === null) { + return yield* new ServerEnvironmentIdPersistenceError({ + operation: "initialize", + environmentIdPath: serverConfig.environmentIdPath, + }); + } + return winner; }); const environmentId = EnvironmentId.make(environmentIdRaw); + return ServerEnvironmentIdentity.of({ + getEnvironmentId: Effect.succeed(environmentId), + }); +}); + +export const make = Effect.gen(function* () { + const path = yield* Path.Path; + const serverConfig = yield* ServerConfig.ServerConfig; + const secrets = yield* ServerSecretStore.ServerSecretStore; + const identity = yield* ServerEnvironmentIdentity; + const hostPlatform = yield* HostProcessPlatform; + const hostArchitecture = yield* HostProcessArchitecture; + const environmentId = yield* identity.getEnvironmentId; const cwdBaseName = path.basename(serverConfig.cwd).trim(); const label = yield* resolveServerEnvironmentLabel({ cwdBaseName }); const launcher = yield* resolveServiceLauncherMode(); @@ -147,12 +206,16 @@ export const make = Effect.gen(function* () { repositoryIdentity: true, connectionProbe: true, attachmentUploads: true, + fileAttachments: { maxUploadBytes: PROVIDER_SEND_TURN_MAX_FILE_BYTES }, pullRequests: true, threadSettlement: true, + threadAutoSettlement: true, threadSnooze: true, + environmentThemes: true, threadPinning: true, threadPinReorder: true, threadTitleRegeneration: true, + threadPullRequestLinking: true, ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }), ...(serverSelfUpdate === "boot-service" ? { serverSelfUpdateProgress: true } : {}), }, @@ -172,10 +235,15 @@ export const make = Effect.gen(function* () { }); }); +export const identityLayer = Layer.effect(ServerEnvironmentIdentity, makeIdentity); + /** * ServerEnvironment is acquired from persisted filesystem and host-process * state. It intentionally has no fallback Layer.succeed value: callers must * provide the external platform services, a ServerConfig, and the * ServerSecretStore backing the descriptor's publishing capability. */ -export const layer = Layer.effect(ServerEnvironment, make).pipe(Layer.provide(ProcessRunner.layer)); +export const layer = Layer.effect(ServerEnvironment, make).pipe( + Layer.provideMerge(identityLayer), + Layer.provide(ProcessRunner.layer), +); diff --git a/apps/server/src/environmentTheme.test.ts b/apps/server/src/environmentTheme.test.ts new file mode 100644 index 000000000000..0d50e020098c --- /dev/null +++ b/apps/server/src/environmentTheme.test.ts @@ -0,0 +1,272 @@ +import { EnvironmentThemeFile } from "@t3tools/contracts"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as Option from "effect/Option"; +import * as Queue from "effect/Queue"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; + +import * as ServerConfig from "./config.ts"; +import * as EnvironmentTheme from "./environmentTheme.ts"; + +const encodeThemeFile = Schema.encodeSync(Schema.fromJsonString(EnvironmentThemeFile)); + +const NIGHTFALL_THEME: EnvironmentThemeFile = { + name: "Nightfall", + appearance: "dark", + canvas: "#1a1b26", + accent: "#7aa2f7", +}; + +/** The standard exported form: a full palette, no seeds. */ +const SHARED_THEME: EnvironmentThemeFile = { + version: 1, + name: "Shared Light", + appearance: "light", + colors: { canvas: "#eff1f5", accent: "#1e66f5" }, +}; + +/** Seeds theme files before the service starts, as a real machine would. */ +const withEnvironmentThemes = ( + seeds: Readonly>, + body: Effect.Effect< + A, + E, + | EnvironmentTheme.EnvironmentThemeService + | ServerConfig.ServerConfig + | FileSystem.FileSystem + | Path.Path + | Scope.Scope + >, +) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-environment-theme-" }); + const themesDir = path.join(baseDir, "userdata", "themes"); + yield* fs.makeDirectory(themesDir, { recursive: true }); + for (const [filename, contents] of Object.entries(seeds)) { + yield* fs.writeFileString(path.join(themesDir, filename), contents); + } + + return yield* body.pipe( + Effect.provide( + EnvironmentTheme.layer.pipe( + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), baseDir)), + ), + ), + ); + }).pipe(Effect.scoped); + +const currentThemes = Effect.gen(function* () { + const environmentTheme = yield* EnvironmentTheme.EnvironmentThemeService; + return yield* environmentTheme.current; +}); + +it.layer(NodeServices.layer)("environment theme", (it) => { + it.effect("publishes nothing when the machine has no theme files", () => + withEnvironmentThemes( + {}, + Effect.gen(function* () { + assert.deepEqual(yield* currentThemes, []); + }), + ), + ); + + it.effect("publishes each file under its filename as the id", () => + withEnvironmentThemes( + { + "nightfall.json": encodeThemeFile(NIGHTFALL_THEME), + "shared-light.json": encodeThemeFile(SHARED_THEME), + }, + Effect.gen(function* () { + const themes = yield* currentThemes; + assert.deepEqual( + themes.map((theme) => theme.id), + ["nightfall", "shared-light"], + ); + assert.deepEqual(themes[0], { id: "nightfall", ...NIGHTFALL_THEME }); + assert.deepEqual(themes[1], { id: "shared-light", ...SHARED_THEME }); + }), + ), + ); + + // Read from disk rather than from the watcher's last observation, so a + // client connecting after a missed filesystem event still sees the truth. + it.effect("follows the directory rather than the set read at start", () => + withEnvironmentThemes( + { "nightfall.json": encodeThemeFile(NIGHTFALL_THEME) }, + Effect.gen(function* () { + const { environmentThemesDir } = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + yield* fs.writeFileString( + path.join(environmentThemesDir, "shared-light.json"), + encodeThemeFile(SHARED_THEME), + ); + assert.equal((yield* currentThemes).length, 2); + + yield* fs.remove(path.join(environmentThemesDir, "nightfall.json")); + assert.deepEqual( + (yield* currentThemes).map((theme) => theme.id), + ["shared-light"], + ); + }), + ), + ); + + // One bad file must not take down the machine's other themes: a theme + // script that leaves a template placeholder unresolved, a half-written + // file, or a stray name are each that file's problem alone. + // The subscription is acquired before the current set is read, so nothing + // published while a client connects can fall between snapshot and stream. + it.effect("streams the current set first", () => + withEnvironmentThemes( + { "nightfall.json": encodeThemeFile(NIGHTFALL_THEME) }, + Effect.gen(function* () { + const environmentTheme = yield* EnvironmentTheme.EnvironmentThemeService; + const first = yield* environmentTheme.streamChanges.pipe(Stream.runHead); + assert.deepEqual(Option.getOrNull(first), [{ id: "nightfall", ...NIGHTFALL_THEME }]); + }), + ), + ); + + // Subscribing happens before the snapshot read, so a publish landing in + // between is queued. It must not replay after the newer snapshot and walk + // clients back onto colors the machine has already moved past. + it.effect("never replays a set older than the snapshot it started from", () => + withEnvironmentThemes( + { "nightfall.json": encodeThemeFile(NIGHTFALL_THEME) }, + Effect.gen(function* () { + const environmentTheme = yield* EnvironmentTheme.EnvironmentThemeService; + const { environmentThemesDir } = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + // Advance the directory twice without the watcher running, so the + // second read is strictly newer than anything already observed. + yield* fs.writeFileString( + path.join(environmentThemesDir, "shared-light.json"), + encodeThemeFile(SHARED_THEME), + ); + const first = yield* environmentTheme.streamChanges.pipe(Stream.runHead); + assert.deepEqual( + Option.getOrNull(first)?.map((theme) => theme.id), + ["nightfall", "shared-light"], + ); + }), + ), + ); + + it.effect("skips invalid files while keeping valid ones", () => + withEnvironmentThemes( + { + "nightfall.json": encodeThemeFile(NIGHTFALL_THEME), + "unresolved.json": + '{ "name": "X", "appearance": "dark", "canvas": "{{ background }}", "accent": "#7aa2f7" }', + "malformed.json": "{ not json", + "no-colors.json": '{ "name": "Empty", "appearance": "dark" }', + "Bad Name.json": encodeThemeFile(SHARED_THEME), + "ocean.json": encodeThemeFile(SHARED_THEME), + "dark.json": encodeThemeFile(SHARED_THEME), + "notes.txt": "not a theme", + }, + Effect.gen(function* () { + assert.deepEqual( + (yield* currentThemes).map((theme) => theme.id), + ["nightfall"], + ); + }), + ), + ); + + // A symlinked themes directory stays usable, but a symlinked file inside it + // must not publish whatever it points at. + it.effect("ignores a symlinked theme file", () => + withEnvironmentThemes( + {}, + Effect.gen(function* () { + const { environmentThemesDir } = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const outside = path.join(environmentThemesDir, "..", "outside.json"); + yield* fs.writeFileString(outside, encodeThemeFile(NIGHTFALL_THEME)); + yield* fs.symlink(outside, path.join(environmentThemesDir, "nightfall.json")); + assert.deepEqual(yield* currentThemes, []); + }), + ), + ); + + // The aggregate size cap charges only accepted themes, so a pile of + // malformed files cannot spend the budget and hide a valid theme sorted + // after them. + it.effect("does not charge skipped files against the total size limit", () => + withEnvironmentThemes( + { + ...Object.fromEntries( + Array.from({ length: 7 }, (_, index) => [`junk-${index}.json`, "{".repeat(30_000)]), + ), + "zz-valid.json": encodeThemeFile(NIGHTFALL_THEME), + }, + Effect.gen(function* () { + assert.deepEqual( + (yield* currentThemes).map((theme) => theme.id), + ["zz-valid"], + ); + }), + ), + ); +}); + +// The feature's headline claim: rewrite a file and connected clients retint +// without a restart. Live clock and a real filesystem event, so this proves +// the watcher rather than a direct read. Kept outside the it.layer block above +// because only the top-level `it` exposes `live`. +describe("environment theme watching", () => { + it.live("streams a set for every change to the directory", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-theme-watch-" }); + const themesDir = path.join(baseDir, "userdata", "themes"); + yield* fs.makeDirectory(themesDir, { recursive: true }); + + yield* Effect.gen(function* () { + const environmentTheme = yield* EnvironmentTheme.EnvironmentThemeService; + const seen = yield* Queue.unbounded>(); + yield* Stream.runForEach(environmentTheme.streamChanges, (themes) => + Queue.offer(seen, themes), + ).pipe(Effect.forkScoped); + + // Empty to start. + assert.deepEqual(yield* Queue.take(seen), []); + + // Published atomically, the way a theme hook writes it. + const staging = path.join(baseDir, "staged.json"); + yield* fs.writeFileString(staging, encodeThemeFile(NIGHTFALL_THEME)); + yield* fs.rename(staging, path.join(themesDir, "nightfall.json")); + assert.deepEqual( + (yield* Queue.take(seen)).map((theme) => theme.id), + ["nightfall"], + ); + + // Removed again, and the set empties without a restart. + yield* fs.remove(path.join(themesDir, "nightfall.json")); + assert.deepEqual(yield* Queue.take(seen), []); + }).pipe( + Effect.provide( + EnvironmentTheme.layer.pipe( + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), baseDir)), + ), + ), + Effect.timeout("30 seconds"), + ); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); +}); diff --git a/apps/server/src/environmentTheme.ts b/apps/server/src/environmentTheme.ts new file mode 100644 index 000000000000..c038af065bdc --- /dev/null +++ b/apps/server/src/environmentTheme.ts @@ -0,0 +1,297 @@ +// @effect-diagnostics nodeBuiltinImport:off - the guarded file read needs open +// flags (O_NOFOLLOW, O_NONBLOCK) the FileSystem service does not expose. +/** + * EnvironmentTheme - palettes this machine publishes for clients to follow. + * + * A desktop that retints its apps when the user switches system theme writes + * `/themes/.json`; this service watches that directory and + * streams the published set to connected clients so a theme change lands + * without a restart. The filename is the theme id: it stays stable while the + * machine rewrites the colors underneath, so `defaultTheme` and a client\'s + * selection keep pointing at the same theme across recolors. Theming is + * cosmetic, so every failure here degrades to "not published" rather than + * propagating. + * + * @module EnvironmentTheme + */ +import * as NodeFS from "node:fs"; + +import { + EnvironmentTheme, + EnvironmentThemeFile, + EnvironmentThemeId, + environmentThemeFileHasColors, +} from "@t3tools/contracts"; +import { UNPUBLISHABLE_THEME_IDS } from "@t3tools/shared/themePalettes"; +import * as Cause from "effect/Cause"; +import * as Context from "effect/Context"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Equal from "effect/Equal"; +import * as Exit from "effect/Exit"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as PubSub from "effect/PubSub"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; +import * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; + +import * as ServerConfig from "./config.ts"; + +const decodeEnvironmentThemeFileJsonExit = Schema.decodeUnknownExit( + Schema.fromJsonString(EnvironmentThemeFile), +); +const isEnvironmentThemeId = Schema.is(EnvironmentThemeId); + +const THEME_FILE_SUFFIX = ".json"; + +/** + * Bounds on what a machine can publish. The directory is local, so this is not + * a trust boundary -- but an accidental dump of large files there would + * otherwise be read in full, streamed to every client, and repainted, so the + * cost of a mistake is capped rather than unbounded. + */ +const MAX_THEME_FILES = 32; +/** Exported so the publish path cannot accept a file the watcher will skip. */ +export const MAX_THEME_FILE_BYTES = 32 * 1024; +/** + * The set travels whole in a websocket event to every subscriber, so the sum + * matters more than any single file. An exported theme runs a few KB, leaving + * this far above any real directory while keeping a mistake off the wire. + */ +const MAX_THEME_TOTAL_BYTES = 192 * 1024; + +/** The published set with the sequence number it was observed at. */ +interface PublishedThemes { + readonly seq: number; + readonly themes: ReadonlyArray; +} + +export class EnvironmentThemeService extends Context.Service< + EnvironmentThemeService, + { + /** + * The set published right now, read from disk rather than from the + * watcher\'s last observation: a client connecting must see what the + * machine actually publishes even if it missed a filesystem event. + */ + readonly current: Effect.Effect>; + + /** + * The current set followed by every change, with repeats dropped. The + * subscription is acquired before the current set is read, so a publish + * landing while a client connects is delivered rather than lost. + */ + readonly streamChanges: Stream.Stream>; + } +>()("t3/environmentTheme/EnvironmentThemeService") {} + +/** + * Reads a theme file through one opened handle, so every check binds to the + * file actually read rather than to a path that may have been swapped since: + * O_NOFOLLOW rejects a symlink outright (a symlinked themes directory stays + * usable, a symlinked file inside it does not), O_NONBLOCK keeps a FIFO from + * blocking the open, and the fstat type and size gate examines the open + * descriptor. Returns null for anything that is not a small regular file. + */ +export const readThemeFileGuarded = (filePath: string, maxBytes: number): string | null => { + let fd: number; + try { + fd = NodeFS.openSync( + filePath, + NodeFS.constants.O_RDONLY | NodeFS.constants.O_NOFOLLOW | NodeFS.constants.O_NONBLOCK, + ); + } catch { + return null; + } + try { + const info = NodeFS.fstatSync(fd); + if (!info.isFile() || info.size > maxBytes) return null; + const contents = Buffer.alloc(info.size); + let offset = 0; + while (offset < contents.length) { + const read = NodeFS.readSync(fd, contents, offset, contents.length - offset, offset); + if (read <= 0) break; + offset += read; + } + return contents.subarray(0, offset).toString("utf8"); + } catch { + return null; + } finally { + NodeFS.closeSync(fd); + } +}; + +/** + * Every theme the directory actually publishes. A file that is missing, + * unreadable, malformed, colorless, or misnamed is simply skipped; the rest of + * the set is unaffected. The one place that decides what "published" means, so + * a caller validating an id cannot disagree with the watcher serving it. + */ +export const readPublishedThemes = Effect.fn(function* (themesDir: string) { + const fs = yield* FileSystem.FileSystem; + const entries = yield* fs + .readDirectory(themesDir) + .pipe(Effect.orElseSucceed((): Array => [])); + + const themes: Array = []; + let examined = 0; + let totalBytes = 0; + for (const entry of entries.toSorted()) { + if (!entry.endsWith(THEME_FILE_SUFFIX)) continue; + const id = entry.slice(0, -THEME_FILE_SUFFIX.length); + // A reserved id is either shadowed by a built-in on the client or captures + // clients that never chose it, so it is not publishable. + if (!isEnvironmentThemeId(id) || UNPUBLISHABLE_THEME_IDS.has(id)) continue; + + // Counts files examined, not themes accepted: capping the output would + // let a directory of malformed files be opened, read, and decoded in full + // on every refresh and every client connect. + examined += 1; + if (examined > MAX_THEME_FILES) { + yield* Effect.logWarning("ignoring environment theme files past the limit", { + path: themesDir, + limit: MAX_THEME_FILES, + }); + break; + } + + const filePath = `${themesDir}/${entry}`; + const raw = readThemeFileGuarded(filePath, MAX_THEME_FILE_BYTES); + if (raw === null) { + yield* Effect.logWarning("ignoring unusable environment theme file", { + path: filePath, + limit: MAX_THEME_FILE_BYTES, + }); + continue; + } + if (raw.trim().length === 0) continue; + + const decoded = decodeEnvironmentThemeFileJsonExit(raw); + if (decoded._tag === "Failure") { + yield* Effect.logWarning("ignoring invalid environment theme", { + path: filePath, + detail: Cause.pretty(decoded.cause), + }); + continue; + } + const file = decoded.value; + if (!environmentThemeFileHasColors(file)) { + yield* Effect.logWarning("ignoring environment theme without colors", { path: filePath }); + continue; + } + + // Counted only once accepted: the cap bounds what travels to clients, so + // a skipped file must not eat the budget of valid themes sorted after it. + // Bytes, not string length -- the cap describes wire weight. + totalBytes += Buffer.byteLength(raw); + if (totalBytes > MAX_THEME_TOTAL_BYTES) { + yield* Effect.logWarning("ignoring environment themes past the total size limit", { + path: themesDir, + limit: MAX_THEME_TOTAL_BYTES, + }); + break; + } + + themes.push({ id, ...file }); + } + return themes; +}); + +/** + * Reads the directory and folds it into the sequenced state, publishing only + * a genuine change. Every reader goes through here, so the snapshot a client + * connects on and the events it then receives come from one ordered source + * rather than from disk and the queue independently. + */ + +const make = Effect.gen(function* () { + const { environmentThemesDir } = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + /** + * Sliding with capacity 1: every update carries the complete set, so a + * subscriber that stops consuming holds at most the newest set rather than + * an unbounded backlog. Every observed set carries a sequence number, so a + * subscriber can drop queued events that predate the snapshot it started + * from. Without it a publish landing between subscribing and reading + * replays after the newer value and walks clients backwards onto stale + * colors. + */ + const changes = yield* PubSub.sliding(1); + const published = yield* Ref.make({ seq: 0, themes: [] }); + /** + * Guards the whole read/compare/publish, not just the state update. The + * directory read is async, so two concurrent refreshes can finish out of + * order and a slower read of an older set would publish under a higher + * sequence -- which the subscriber filter, ordering publications rather than + * observations, could not then drop. + */ + const refreshSemaphore = yield* Semaphore.make(1); + const watcherScope = yield* Scope.make("sequential"); + yield* Effect.addFinalizer(() => Scope.close(watcherScope, Exit.void)); + + const refresh = refreshSemaphore.withPermits(1)( + Effect.gen(function* () { + const themes = yield* readPublishedThemes(environmentThemesDir).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + ); + // Structural equality over the whole decoded value: a hand-rolled field + // list here silently drops republishes for any field it forgets. + const [changed, next] = yield* Ref.modify( + published, + (previous): readonly [readonly [boolean, PublishedThemes], PublishedThemes] => { + if (Equal.equals(previous.themes, themes)) return [[false, previous], previous]; + const updated: PublishedThemes = { seq: previous.seq + 1, themes }; + return [[true, updated], updated]; + }, + ); + if (changed) yield* PubSub.publish(changes, next).pipe(Effect.asVoid); + return next; + }), + ); + + // The directory is created up front so the watcher has something to attach + // to before the first publisher writes into it. + yield* fs + .makeDirectory(environmentThemesDir, { recursive: true }) + .pipe(Effect.ignoreCause({ log: true })); + + // Debounced for the same reason settings watching is: a theme script emits + // several events per save and `fs.watch` can fire before the content is + // flushed. Every event triggers a full re-read, so no event needs filtering. + const watchEvents = fs.watch(environmentThemesDir).pipe(Stream.debounce(Duration.millis(100))); + + // Seeds the dedupe so a watch event that reports no actual change (a touch, + // a rewrite with identical contents) does not retint every client. + yield* refresh; + yield* Stream.runForEach(watchEvents, () => refresh.pipe(Effect.ignoreCause({ log: true }))).pipe( + Effect.ignoreCause({ log: true }), + Effect.forkIn(watcherScope), + Effect.asVoid, + ); + + return { + current: Effect.map(refresh, (state) => state.themes), + get streamChanges() { + return Stream.unwrap( + Effect.gen(function* () { + // Subscribe first so nothing published during the read is missed, + // then drop anything the snapshot already accounts for. + const subscription = yield* PubSub.subscribe(changes); + const snapshot = yield* refresh; + return Stream.concat( + Stream.make(snapshot.themes), + Stream.fromSubscription(subscription).pipe( + Stream.filter((update) => update.seq > snapshot.seq), + Stream.map((update) => update.themes), + ), + ); + }), + ); + }, + } satisfies EnvironmentThemeService["Service"]; +}); + +export const layer = Layer.effect(EnvironmentThemeService, make); diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 9e2cf15ecb72..fc2a2c81279d 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -620,6 +620,7 @@ function makeManager(input?: { textGeneration?: Partial; serverSettings?: Parameters[0]; setupScriptRunner?: ProjectSetupScriptRunner.ProjectSetupScriptRunner["Service"]; + gitConfigReads?: string[]; }) { const { service: gitHubCli, ghCalls } = createGitHubCliWithFakeGh(input?.ghScenario); const textGeneration = createTextGeneration(input?.textGeneration); @@ -629,11 +630,30 @@ function makeManager(input?: { const serverSettingsLayer = ServerSettings.ServerSettingsService.layerTest(input?.serverSettings); - const vcsDriverLayer = GitVcsDriver.layer.pipe( - Layer.provideMerge(VcsProcess.layer), - Layer.provideMerge(NodeServices.layer), - Layer.provideMerge(serverConfigLayer), - ); + const vcsDriverLayer = input?.gitConfigReads + ? Layer.effect( + GitVcsDriver.GitVcsDriver, + GitVcsDriver.make.pipe( + Effect.map((service) => + GitVcsDriver.GitVcsDriver.of({ + ...service, + readConfigValue: (cwd, key) => + Effect.sync(() => input.gitConfigReads?.push(key)).pipe( + Effect.andThen(service.readConfigValue(cwd, key)), + ), + }), + ), + ), + ).pipe( + Layer.provideMerge(VcsProcess.layer), + Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(serverConfigLayer), + ) + : GitVcsDriver.layer.pipe( + Layer.provideMerge(VcsProcess.layer), + Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(serverConfigLayer), + ); const sourceControlRegistryLayer = Layer.effect( SourceControlProviderRegistry.SourceControlProviderRegistry, GitHubSourceControlProvider.make.pipe( @@ -955,6 +975,30 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); + it.effect("a warm PR cache does not reread repository identity for status", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/status-identity-cache"]); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/status-identity-cache"]); + + const gitConfigReads: string[] = []; + const { manager } = yield* makeManager({ gitConfigReads }); + + yield* manager.remoteStatus({ cwd: repoDir }, { refreshUpstream: false }); + gitConfigReads.length = 0; + yield* manager.remoteStatus({ cwd: repoDir }, { refreshUpstream: false }); + + const identityReads = gitConfigReads.filter( + (key) => + key === "branch.feature/status-identity-cache.remote" || key === "remote.origin.url", + ); + expect(identityReads).toHaveLength(0); + }), + ); + it.effect("status skips the provider lookup for a branch that was never pushed", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); @@ -974,6 +1018,377 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); + it.effect("branch PR lookup returns null when the repository has no remotes", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const { manager, ghCalls } = yield* makeManager(); + + const pullRequest = yield* manager.branchPullRequest({ cwd: repoDir, branch: "main" }); + + expect(pullRequest).toBeNull(); + expect(ghCalls).toHaveLength(0); + }), + ); + + it.effect("branch PR lookup uses a saved tracked branch without changing checkout", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["checkout", "-b", "feature/saved-branch"]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/saved-branch"]); + yield* runGit(repoDir, ["checkout", "main"]); + + const { manager } = yield* makeManager({ + ghScenario: { + prListSequence: [ + // Fake gh returns raw JSON stdout, matching the CLI boundary under test. + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + { + number: 216, + title: "Saved branch PR", + url: "https://github.com/pingdotgg/t3code/pull/216", + baseRefName: "main", + headRefName: "feature/saved-branch", + state: "OPEN", + updatedAt: "2026-04-03T15:00:00Z", + }, + ]), + ], + }, + }); + + const pullRequest = yield* manager.branchPullRequest({ + cwd: repoDir, + branch: "feature/saved-branch", + }); + + expect(pullRequest).toEqual({ + state: "open", + updatedAt: "2026-04-03T15:00:00.000Z", + }); + expect((yield* runGit(repoDir, ["branch", "--show-current"])).stdout.trim()).toBe("main"); + }), + ); + + it.effect("branch PR lookup uses the default branch from a non-origin remote", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "upstream", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "upstream", "main"]); + yield* runGit(repoDir, ["checkout", "-b", "develop"]); + yield* runGit(repoDir, ["push", "-u", "upstream", "develop"]); + yield* runGit(remoteDir, ["symbolic-ref", "HEAD", "refs/heads/develop"]); + yield* runGit(repoDir, ["remote", "set-head", "upstream", "develop"]); + + const { manager } = yield* makeManager({ + ghScenario: { + // Fake gh returns raw JSON stdout, matching the CLI boundary under test. + prListSequence: [ + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + { + number: 221, + title: "Merged main PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/221", + baseRefName: "develop", + headRefName: "main", + state: "MERGED", + updatedAt: "2026-04-08T15:00:00Z", + }, + ]), + ], + }, + }); + + const pullRequest = yield* manager.branchPullRequest({ cwd: repoDir, branch: "main" }); + + expect(pullRequest).toEqual({ + state: "merged", + updatedAt: "2026-04-08T15:00:00.000Z", + }); + }), + ); + + it.effect("branch PR lookup uses the saved name after the local branch is deleted", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["checkout", "-b", "feature/deleted-local-branch"]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/deleted-local-branch"]); + yield* runGit(repoDir, ["checkout", "main"]); + yield* runGit(repoDir, ["branch", "-D", "feature/deleted-local-branch"]); + yield* runGit(repoDir, ["branch", "feature/deleted-local-branch/child"]); + yield* runGit(repoDir, [ + "branch", + "--set-upstream-to", + "origin/main", + "feature/deleted-local-branch/child", + ]); + + const { manager, ghCalls } = yield* makeManager({ + ghScenario: { + prListSequence: [ + // Fake gh returns raw JSON stdout, matching the CLI boundary under test. + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + { + number: 217, + title: "Deleted local branch PR", + url: "https://github.com/pingdotgg/t3code/pull/217", + baseRefName: "main", + headRefName: "feature/deleted-local-branch", + state: "MERGED", + updatedAt: "2026-04-04T15:00:00Z", + }, + ]), + ], + }, + }); + + const pullRequest = yield* manager.branchPullRequest({ + cwd: repoDir, + branch: "feature/deleted-local-branch", + }); + + expect(pullRequest).toEqual({ + state: "merged", + updatedAt: "2026-04-04T15:00:00.000Z", + }); + expect(ghCalls.some((call) => call.includes("--head feature/deleted-local-branch"))).toBe( + true, + ); + }), + ); + + it.effect("branch PR lookup recovers a deleted fork branch from its remote-tracking ref", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const originDir = yield* createBareRemote(); + const forkDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", originDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* configureRemote(repoDir, "team/fork", forkDir, "team/fork"); + yield* runGit(repoDir, ["checkout", "-b", "feature/deleted-fork-branch"]); + yield* runGit(repoDir, ["push", "-u", "team/fork", "feature/deleted-fork-branch"]); + yield* runGit(repoDir, ["checkout", "main"]); + yield* runGit(repoDir, ["branch", "-D", "feature/deleted-fork-branch"]); + yield* configureVisibleRemoteUrlWithLocalRewrite( + repoDir, + "origin", + "git@github.com:pingdotgg/codething-mvp.git", + originDir, + ); + yield* configureVisibleRemoteUrlWithLocalRewrite( + repoDir, + "team/fork", + "git@github.com:contributor/codething-mvp.git", + forkDir, + ); + + const { manager, ghCalls } = yield* makeManager({ + ghScenario: { + prListByHeadSelector: { + // Fake gh returns raw JSON stdout, matching the CLI boundary under test. + // @effect-diagnostics-next-line preferSchemaOverJson:off + "contributor:feature/deleted-fork-branch": JSON.stringify([ + { + number: 218, + title: "Deleted fork branch PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/218", + baseRefName: "main", + headRefName: "feature/deleted-fork-branch", + state: "MERGED", + updatedAt: "2026-04-05T15:00:00Z", + isCrossRepository: true, + headRepository: { nameWithOwner: "contributor/codething-mvp" }, + headRepositoryOwner: { login: "contributor" }, + }, + ]), + }, + }, + }); + + const pullRequest = yield* manager.branchPullRequest({ + cwd: repoDir, + branch: "feature/deleted-fork-branch", + }); + + expect(pullRequest).toEqual({ + state: "merged", + updatedAt: "2026-04-05T15:00:00.000Z", + }); + expect( + ghCalls.some((call) => call.includes("--head contributor:feature/deleted-fork-branch")), + ).toBe(true); + }), + ); + + it.effect("branch PR lookup rejects ambiguous deleted-branch remote refs", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const originDir = yield* createBareRemote(); + const forkDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", originDir]); + yield* runGit(repoDir, ["remote", "add", "fork", forkDir]); + yield* runGit(repoDir, ["checkout", "-b", "feature/ambiguous-remote"]); + yield* runGit(repoDir, ["push", "origin", "feature/ambiguous-remote"]); + yield* runGit(repoDir, ["push", "fork", "feature/ambiguous-remote"]); + yield* runGit(repoDir, ["checkout", "main"]); + yield* runGit(repoDir, ["branch", "-D", "feature/ambiguous-remote"]); + const { manager, ghCalls } = yield* makeManager(); + + const error = yield* manager + .branchPullRequest({ cwd: repoDir, branch: "feature/ambiguous-remote" }) + .pipe(Effect.flip); + + expect(error).toMatchObject({ + _tag: "GitManagerError", + detail: "Multiple remotes track feature/ambiguous-remote. Its pull request is ambiguous.", + }); + expect(ghCalls).toHaveLength(0); + }), + ); + + it.effect("branch PR lookup does not reuse a cached PR after the remote is repointed", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const originalRemoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", originalRemoteDir]); + yield* runGit(repoDir, ["checkout", "-b", "feature/repointed-lookup"]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/repointed-lookup"]); + yield* configureVisibleRemoteUrlWithLocalRewrite( + repoDir, + "origin", + "git@github.com:old-owner/old-repository.git", + originalRemoteDir, + ); + const { manager, ghCalls } = yield* makeManager({ + ghScenario: { + prListSequence: [ + // Fake gh returns raw JSON stdout, matching the CLI boundary under test. + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + { + number: 219, + title: "Old repository PR", + url: "https://github.com/old-owner/old-repository/pull/219", + baseRefName: "main", + headRefName: "feature/repointed-lookup", + state: "MERGED", + updatedAt: "2026-04-06T15:00:00Z", + }, + ]), + "[]", + ], + }, + }); + + const first = yield* manager.branchPullRequest({ + cwd: repoDir, + branch: "feature/repointed-lookup", + }); + expect(first?.state).toBe("merged"); + + const replacementRemoteDir = yield* createBareRemote(); + yield* configureVisibleRemoteUrlWithLocalRewrite( + repoDir, + "origin", + "git@github.com:new-owner/new-repository.git", + replacementRemoteDir, + ); + + const second = yield* manager.branchPullRequest({ + cwd: repoDir, + branch: "feature/repointed-lookup", + }); + + expect(second).toBeNull(); + expect(ghCalls.filter((call) => call.startsWith("pr list "))).toHaveLength(2); + }), + ); + + it.effect("branch PR lookup shares the status cache for the same repository identity", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["checkout", "-b", "feature/shared-pr-cache"]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/shared-pr-cache"]); + const { manager, ghCalls } = yield* makeManager({ + ghScenario: { + prListSequence: [ + // Fake gh returns raw JSON stdout, matching the CLI boundary under test. + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + { + number: 220, + title: "Shared cache PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/220", + baseRefName: "main", + headRefName: "feature/shared-pr-cache", + state: "MERGED", + updatedAt: "2026-04-07T15:00:00Z", + }, + ]), + ], + }, + }); + + const status = yield* manager.status({ cwd: repoDir }); + const pullRequest = yield* manager.branchPullRequest({ + cwd: repoDir, + branch: "feature/shared-pr-cache", + }); + + expect(status.pr?.state).toBe("merged"); + expect(pullRequest?.state).toBe("merged"); + expect(ghCalls.filter((call) => call.startsWith("pr list "))).toHaveLength(1); + }), + ); + + it.effect("branch PR lookup propagates provider failures", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["checkout", "-b", "feature/lookup-failure"]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/lookup-failure"]); + yield* runGit(repoDir, ["checkout", "main"]); + + const { manager } = yield* makeManager({ + ghScenario: { + failWith: new GitHubCli.GitHubCliUnavailableError({ + command: "gh", + cwd: repoDir, + cause: new Error("gh is not available on PATH"), + }), + }, + }); + + const error = yield* manager + .branchPullRequest({ cwd: repoDir, branch: "feature/lookup-failure" }) + .pipe(Effect.flip); + + expect(error._tag).toBe("SourceControlProviderError"); + }), + ); + it.effect("status finds a merged PR after its remote branch was deleted", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); @@ -1967,18 +2382,26 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); - it.effect("preserves repository conventions style when recent history is empty", () => + it.effect("includes local agent instructions when recent history is empty", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* runGit(repoDir, ["init", "--initial-branch=main"]); yield* runGit(repoDir, ["config", "user.email", "test@example.com"]); yield* runGit(repoDir, ["config", "user.name", "Test User"]); + const agentInstructions = "Use lowercase source control text."; + const claudeInstructions = "Keep pull request bodies brief."; + NodeFS.writeFileSync(NodePath.join(repoDir, "AGENTS.md"), agentInstructions); + NodeFS.writeFileSync(NodePath.join(repoDir, "CLAUDE.md"), claudeInstructions); NodeFS.writeFileSync(NodePath.join(repoDir, "README.md"), "hello\n"); yield* runGit(repoDir, ["add", "README.md"]); let generatedPolicy: TextGeneration.CommitMessageGenerationInput["policy"] = undefined; const { manager } = yield* makeManager({ serverSettings: { + textGenerationModelSelection: { + instanceId: ProviderInstanceId.make("claudeAgent"), + model: "claude-sonnet-4-6", + }, sourceControlWritingStyle: { mode: "repo_conventions" as const, }, @@ -1997,10 +2420,8 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { expect(generatedPolicy).toEqual({ kind: "repo_conventions", - commitInstructions: - "Follow the repository's established commit message style when examples are available.", - changeRequestInstructions: - "Follow the repository's established change request title and body style when examples are available.", + commitInstructions: `Follow the repository's established commit message style when examples are available.\n\nLocal AGENTS.md:\n${agentInstructions}\n\nLocal CLAUDE.md:\n${claudeInstructions}`, + changeRequestInstructions: `Follow the repository's established change request title and body style when examples are available.\n\nLocal AGENTS.md:\n${agentInstructions}\n\nLocal CLAUDE.md:\n${claudeInstructions}`, inferRepositoryConventions: true, }); }), diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index 5ea4a0072d66..393a8fd05592 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -90,6 +90,14 @@ export class GitManager extends Context.Service< input: VcsStatusInput, options?: GitVcsDriver.GitRemoteStatusOptions, ) => Effect.Effect; + /** Resolve the PR for a saved branch without changing the current checkout. */ + readonly branchPullRequest: (input: { + readonly cwd: string; + readonly branch: string; + }) => Effect.Effect< + { readonly state: "open" | "closed" | "merged"; readonly updatedAt: string | null } | null, + GitManagerServiceError + >; readonly invalidateLocalStatus: (cwd: string) => Effect.Effect; readonly invalidateRemoteStatus: (cwd: string) => Effect.Effect; readonly invalidateStatus: (cwd: string) => Effect.Effect; @@ -181,6 +189,7 @@ interface BranchHeadContext { preferredHeadSelector: string; remoteName: string | null; headRemoteUrlKey: string | null; + targetRemoteUrlKey: string | null; headRepositoryNameWithOwner: string | null; headRepositoryOwnerLogin: string | null; isCrossRepository: boolean; @@ -606,9 +615,24 @@ export const make = Effect.gen(function* () { const providerRegistry = yield* ProviderRegistry.ProviderRegistry; const projectSetupScriptRunner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner; const crypto = yield* Crypto.Crypto; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; const sourceControlProvider = (cwd: string) => sourceControlProviders.resolve({ cwd }); const serverSettingsService = yield* ServerSettings.ServerSettingsService; + const readRepositoryInstructions = (cwd: string, fileName: string) => + Effect.gen(function* () { + const root = yield* fileSystem.realPath(cwd); + const instructionPath = yield* fileSystem.realPath(path.join(root, fileName)); + if (!instructionPath.startsWith(`${root}${path.sep}`)) { + return ""; + } + const info = yield* fileSystem.stat(instructionPath); + if (info.type !== "File" || info.size > FileSystem.Size(20_000)) { + return ""; + } + return (yield* fileSystem.readFileString(instructionPath)).trim(); + }).pipe(Effect.orElseSucceed(() => "")); const readRecentCommitSubjects = (cwd: string) => gitCore @@ -627,26 +651,43 @@ export const make = Effect.gen(function* () { Effect.orElseSucceed(() => []), ); - const resolveStylePolicy = (cwd: string, style: SourceControlWritingStyleSettings) => + const resolveStylePolicy = (cwd: string, settings: SourceControlTextGenerationSettings) => Effect.gen(function* () { - switch (style.mode) { + switch (settings.style.mode) { case "conventional_commits": return conventionalCommitsTextGenerationPolicy; case "custom": return customTextGenerationPolicy( - style.customInstructions + settings.style.customInstructions ? { - commitInstructions: style.customInstructions, - changeRequestInstructions: style.customInstructions, + commitInstructions: settings.style.customInstructions, + changeRequestInstructions: settings.style.customInstructions, } : {}, ); case "repo_conventions": { const subjects = yield* readRecentCommitSubjects(cwd); - if (subjects.length === 0) { + const agentInstructions = yield* readRepositoryInstructions(cwd, "AGENTS.md"); + const isClaudeWriter = + settings.modelSelection.instanceId === "claudeAgent" || + (yield* providerRegistry.getProviders).some( + (provider) => + provider.instanceId === settings.modelSelection.instanceId && + provider.driver === "claudeAgent", + ); + const claudeInstructions = isClaudeWriter + ? yield* readRepositoryInstructions(cwd, "CLAUDE.md") + : ""; + const examples = [ + ...(subjects.length > 0 + ? [["Recent commit subjects from this repository:", ...subjects].join("\n")] + : []), + ...(agentInstructions ? [`Local AGENTS.md:\n${agentInstructions}`] : []), + ...(claudeInstructions ? [`Local CLAUDE.md:\n${claudeInstructions}`] : []), + ].join("\n\n"); + if (!examples) { return repositoryConventionsTextGenerationPolicy; } - const examples = ["Recent commit subjects from this repository:", ...subjects].join("\n"); return { ...repositoryConventionsTextGenerationPolicy, commitInstructions: `${repositoryConventionsTextGenerationPolicy.commitInstructions}\n\n${examples}`, @@ -848,9 +889,6 @@ export const make = Effect.gen(function* () { ), ), ); - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const tempDir = process.env.TMPDIR ?? process.env.TEMP ?? process.env.TMP ?? "/tmp"; const canonicalizeExistingPath = (value: string) => fileSystem.realPath(value).pipe(Effect.orElseSucceed(() => value)); @@ -909,15 +947,16 @@ export const make = Effect.gen(function* () { prLookupEpochByCwd.set(cacheKey, prLookupEpoch(cacheKey) + 1); }), ); - // Cache keys are NUL-joined [cwd, branch, upstreamRef, defaultBranch, epoch] — none of the - // segments can contain a NUL byte, and refs are never empty, so "" decodes - // back to a null ref. + // Cache keys are NUL-joined. Automatic settlement validates repository URLs + // against the cached value before it uses a pull request decision. const prLookupCacheKey = ( cwd: string, details: { branch: string; upstreamRef: string | null; defaultBranch: string | null; + localBranchExists?: boolean; + remoteName?: string | null; }, ) => [ @@ -925,6 +964,8 @@ export const make = Effect.gen(function* () { details.branch, details.upstreamRef ?? "", details.defaultBranch ?? "", + details.localBranchExists === false ? "0" : "1", + details.remoteName ?? "", String(prLookupEpoch(cwd)), ].join("\u0000"); // Consecutive failures per cache key, so a branch that keeps failing waits @@ -946,11 +987,20 @@ export const make = Effect.gen(function* () { }; const prLookupCache = yield* Cache.makeWith( (key: string) => { - const [cwd = "", branch = "", upstreamRef = "", defaultBranch = ""] = key.split("\u0000"); + const [ + cwd = "", + branch = "", + upstreamRef = "", + defaultBranch = "", + branchExists = "1", + remoteName = "", + ] = key.split("\u0000"); const details = { branch, upstreamRef: upstreamRef.length > 0 ? upstreamRef : null, defaultBranch: defaultBranch.length > 0 ? defaultBranch : null, + localBranchExists: branchExists !== "0", + ...(remoteName.length > 0 ? { remoteName } : {}), }; return Effect.gen(function* () { const headContext = yield* resolveBranchHeadContext(cwd, details); @@ -971,7 +1021,11 @@ export const make = Effect.gen(function* () { } // Only skip when the branch is untracked as well: anything carrying an // upstream keeps the old behaviour. - if (details.upstreamRef === null && (yield* isUnpublishedBranch(cwd, headContext))) { + if ( + details.localBranchExists && + details.upstreamRef === null && + (yield* isUnpublishedBranch(cwd, headContext)) + ) { return { latest: null, headContext }; } const latest = yield* findLatestPrForHeadContext(cwd, headContext); @@ -1189,11 +1243,33 @@ export const make = Effect.gen(function* () { }; }); + const resolvePrLookupRepositoryIdentity = Effect.fn("resolvePrLookupRepositoryIdentity")( + function* (cwd: string, branch: string, remoteNameOverride?: string) { + const remoteName = + remoteNameOverride ?? (yield* readConfigValueNullable(cwd, `branch.${branch}.remote`)); + const [headRemote, targetRemote] = yield* Effect.all( + [ + resolveRemoteRepositoryContext(cwd, remoteName), + resolveRemoteRepositoryContext(cwd, "origin"), + ], + { concurrency: "unbounded" }, + ); + return { + remoteName, + headRemoteUrlKey: + headRemote.remoteUrlKey ?? (remoteName === null ? targetRemote.remoteUrlKey : null), + targetRemoteUrlKey: targetRemote.remoteUrlKey, + }; + }, + ); + const resolveBranchHeadContext = Effect.fn("resolveBranchHeadContext")(function* ( cwd: string, - details: { branch: string; upstreamRef: string | null }, + details: { branch: string; upstreamRef: string | null; remoteName?: string }, ) { - const remoteName = yield* readConfigValueNullable(cwd, `branch.${details.branch}.remote`); + const remoteName = + details.remoteName ?? + (yield* readConfigValueNullable(cwd, `branch.${details.branch}.remote`)); const headBranchFromUpstream = details.upstreamRef ? extractBranchNameFromRemoteRef(details.upstreamRef, { remoteName }) : ""; @@ -1257,6 +1333,7 @@ export const make = Effect.gen(function* () { headRemoteUrlKey: remoteRepository.remoteUrlKey ?? (remoteName === null ? originRepository.remoteUrlKey : null), + targetRemoteUrlKey: originRepository.remoteUrlKey, headRepositoryNameWithOwner: remoteRepository.repositoryNameWithOwner, headRepositoryOwnerLogin: remoteRepository.ownerLogin, isCrossRepository, @@ -1565,7 +1642,7 @@ export const make = Effect.gen(function* () { }; } - const policy = yield* resolveStylePolicy(input.cwd, input.settings.style); + const policy = yield* resolveStylePolicy(input.cwd, input.settings); const generated = yield* textGeneration .generateCommitMessage({ @@ -1751,7 +1828,7 @@ export const make = Effect.gen(function* () { }); const baseRangeRef = yield* resolveBaseRangeRef(cwd, baseBranch); const rangeContext = yield* gitCore.readRangeContext(cwd, baseRangeRef); - const policy = yield* resolveStylePolicy(cwd, settings.style); + const policy = yield* resolveStylePolicy(cwd, settings); const changeRequestTemplate = settings.style.followChangeRequestTemplates && provider.kind === "github" ? Option.getOrUndefined(yield* detectPrTemplate(cwd, baseRangeRef, gitCore.execute)) @@ -1840,6 +1917,140 @@ export const make = Effect.gen(function* () { }); return mergeGitStatusParts(local, remote); }); + const branchPullRequest: GitManager["Service"]["branchPullRequest"] = Effect.fn( + "branchPullRequest", + )(function* ({ cwd, branch }) { + const cacheCwd = yield* normalizeStatusCacheKey(cwd); + const remotes = yield* gitCore.execute({ + operation: "GitManager.branchPullRequest.remotes", + cwd: cacheCwd, + args: ["remote"], + }); + const remoteNames = remotes.stdout + .split("\n") + .map((remoteName) => remoteName.trim()) + .filter((remoteName) => remoteName.length > 0); + const [firstRemoteName] = remoteNames; + if (firstRemoteName === undefined) return null; + const branchRef = yield* gitCore.execute({ + operation: "GitManager.branchPullRequest.branchRef", + cwd: cacheCwd, + args: [ + "for-each-ref", + "--format=%(refname)%00%(upstream:short)%00%(upstream:remotename)%00%(upstream:remoteref)", + `refs/heads/${branch}`, + ], + }); + const expectedRefName = `refs/heads/${branch}`; + const exactBranch = branchRef.stdout + .split("\n") + .find((line) => line.split("\u0000", 1)[0] === expectedRefName); + const [refName = "", savedUpstream = "", savedRemoteName = "", savedRemoteRef = ""] = + exactBranch?.split("\u0000") ?? []; + const localBranchExists = refName.length > 0; + let upstreamRef: string | null = null; + let remoteName: string | null = null; + if (savedUpstream.length > 0) { + if (savedRemoteName.length === 0 || savedRemoteRef.length === 0) { + return yield* new GitManagerError({ + operation: "branchPullRequest", + cwd: cacheCwd, + detail: `Saved upstream for ${branch} is incomplete.`, + }); + } + remoteName = savedRemoteName; + const upstreamBranch = savedRemoteRef.replace(/^refs\/heads\//, ""); + upstreamRef = `${remoteName}/${upstreamBranch}`; + } else if (!localBranchExists) { + const trackingRefs = yield* gitCore.execute({ + operation: "GitManager.branchPullRequest.remoteTrackingRefs", + cwd: cacheCwd, + args: ["for-each-ref", "--format=%(refname)", "refs/remotes"], + }); + const refNames = new Set( + trackingRefs.stdout + .split("\n") + .map((remoteRef) => remoteRef.trim()) + .filter((remoteRef) => remoteRef.length > 0), + ); + const matchingRemoteNames = remoteNames.filter((candidate) => + refNames.has(`refs/remotes/${candidate}/${branch}`), + ); + if (matchingRemoteNames.length > 1) { + return yield* new GitManagerError({ + operation: "branchPullRequest", + cwd: cacheCwd, + detail: `Multiple remotes track ${branch}. Its pull request is ambiguous.`, + }); + } + remoteName = matchingRemoteNames[0] ?? null; + if (remoteName !== null) { + upstreamRef = `${remoteName}/${branch}`; + } + } + const defaultRemoteName = remoteNames.includes("origin") ? "origin" : firstRemoteName; + const defaultBranch = yield* gitCore + .resolveDefaultBranchName(cacheCwd, defaultRemoteName) + .pipe(Effect.orElseSucceed(() => null)); + const cacheKey = prLookupCacheKey(cacheCwd, { + branch, + upstreamRef, + defaultBranch, + localBranchExists, + ...(localBranchExists ? {} : { remoteName }), + }); + let cached = yield* Cache.get(prLookupCache, cacheKey); + const currentIdentity = yield* resolvePrLookupRepositoryIdentity( + cacheCwd, + branch, + remoteName ?? undefined, + ); + const canVerifyIdentity = (headContext: BranchHeadContext, identity: typeof currentIdentity) => + !( + (headContext.headRemoteUrlKey !== null && identity.headRemoteUrlKey === null) || + (headContext.targetRemoteUrlKey !== null && identity.targetRemoteUrlKey === null) + ); + const hasSameIdentity = (headContext: BranchHeadContext, identity: typeof currentIdentity) => + headContext.headRemoteUrlKey === identity.headRemoteUrlKey && + headContext.targetRemoteUrlKey === identity.targetRemoteUrlKey; + if (!canVerifyIdentity(cached.headContext, currentIdentity)) { + return yield* new GitManagerError({ + operation: "branchPullRequest", + cwd: cacheCwd, + detail: `Repository identity for ${branch} could not be verified.`, + }); + } + if (!hasSameIdentity(cached.headContext, currentIdentity)) { + yield* Cache.invalidate(prLookupCache, cacheKey); + cached = yield* Cache.get(prLookupCache, cacheKey); + const refreshedIdentity = yield* resolvePrLookupRepositoryIdentity( + cacheCwd, + branch, + remoteName ?? undefined, + ); + if ( + !canVerifyIdentity(cached.headContext, refreshedIdentity) || + !hasSameIdentity(cached.headContext, refreshedIdentity) + ) { + return yield* new GitManagerError({ + operation: "branchPullRequest", + cwd: cacheCwd, + detail: `Repository identity for ${branch} changed during pull request lookup.`, + }); + } + } + const { latest } = cached; + if (latest === null) return null; + if ( + (branch === defaultBranch || + (defaultBranch === null && (branch === "main" || branch === "master"))) && + latest.state !== "open" + ) { + return null; + } + const statusPr = toStatusPr(latest); + return { state: statusPr.state, updatedAt: statusPr.updatedAt }; + }); const invalidateLocalStatus: GitManager["Service"]["invalidateLocalStatus"] = Effect.fn( "invalidateLocalStatus", )(function* (cwd) { @@ -2387,6 +2598,7 @@ export const make = Effect.gen(function* () { localStatus, remoteStatus, status, + branchPullRequest, invalidateLocalStatus, invalidateRemoteStatus, invalidateStatus, diff --git a/apps/server/src/http.test.ts b/apps/server/src/http.test.ts index f85de08d40b4..9d54adef8eda 100644 --- a/apps/server/src/http.test.ts +++ b/apps/server/src/http.test.ts @@ -1,7 +1,275 @@ import { expect, it } from "@effect/vitest"; -import { describe } from "vite-plus/test"; +import { describe, vi } from "vite-plus/test"; +import * as NodeHttpPlatform from "@effect/platform-node/NodeHttpPlatform"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import { HttpServerResponse } from "effect/unstable/http"; +import { openMediaFile } from "./assets/MediaFile.ts"; -import { assetResponseHeaders, isLoopbackHostname, resolveDevRedirectUrl } from "./http.ts"; +import { + assetResponseHeaders, + assetFileResponse, + downloadContentDisposition, + isLoopbackHostname, + resolveDevRedirectUrl, +} from "./http.ts"; + +const fileResponseLayer = Layer.mergeAll(NodeHttpPlatform.layer, NodeServices.layer); + +describe("video asset byte ranges", () => { + it.effect("uses current descriptor metadata after an in-place truncate or extension", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-guarded-current-stat-" }); + const filePath = path.join(directory, "clip.mp4"); + for (const [contents, range, method, expected, status, contentRange] of [ + ["1234", undefined, "GET", "1234", 200, null], + ["0123456789abcdef", undefined, "GET", "0123456789abcdef", 200, null], + ["1234", "bytes=4-", "GET", "", 416, "bytes */4"], + ["1234", "bytes=1-20", "GET", "234", 206, "bytes 1-3/4"], + ["0123456789abcdef", "bytes=10-", "GET", "abcdef", 206, "bytes 10-15/16"], + ["0123456789abcdef", undefined, "HEAD", "", 200, null], + ["", undefined, "GET", "", 200, null], + ["", "bytes=0-1", "GET", "", 416, "bytes */0"], + ] as const) { + yield* fs.writeFileString(filePath, "0123456789"); + const canonicalPath = yield* fs.realPath(filePath); + const file = yield* openMediaFile(canonicalPath); + if (!file) throw new Error("Expected an opened media file"); + yield* fs.writeFileString(filePath, contents); + const response = HttpServerResponse.toWeb( + yield* assetFileResponse( + { path: canonicalPath, file, mimeType: "video/mp4" }, + range, + undefined, + method, + ), + ); + expect(response.status).toBe(status); + expect(response.headers.get("content-range")).toBe(contentRange); + if (status !== 416) { + expect(response.headers.get("content-length")).toBe( + String(method === "HEAD" ? contents.length : expected.length), + ); + } + expect(yield* Effect.promise(() => response.text())).toBe(expected); + } + }).pipe(Effect.provide(fileResponseLayer)), + ); + + it.effect( + "rejects unaddressable ranges before streaming and preserves small ranges on large files", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-guarded-offset-limit-" }); + const filePath = path.join(directory, "clip.mp4"); + yield* fs.writeFileString(filePath, "0123456789"); + const canonicalPath = yield* fs.realPath(filePath); + const unsafeOffset = BigInt(Number.MAX_SAFE_INTEGER) + 1n; + const size = unsafeOffset + 32n; + for (const [range, status] of [ + [`bytes=${unsafeOffset}-${unsafeOffset}`, 416], + [`bytes=0-${unsafeOffset}`, 416], + ["bytes=-1", 416], + [undefined, 413], + ["bytes=0-1", 206], + ] as const) { + const file = yield* openMediaFile(canonicalPath); + if (!file) throw new Error("Expected an opened media file"); + // Model a sparse file beyond the native stream's numeric addressing limit. + const info = yield* Effect.promise(() => file.handle.stat({ bigint: true })); + info.size = size; + const statSpy = vi.spyOn(file.handle, "stat").mockResolvedValue(info); + yield* Effect.addFinalizer(() => Effect.sync(() => statSpy.mockRestore())); + const response = HttpServerResponse.toWeb( + yield* assetFileResponse({ path: canonicalPath, file, mimeType: "video/mp4" }, range), + ); + expect(response.status).toBe(status); + if (status === 416) { + expect(response.headers.get("content-range")).toBe(`bytes */${size}`); + expect(yield* Effect.promise(() => response.text())).toBe(""); + } else if (status === 206) { + expect(response.headers.get("content-range")).toBe(`bytes 0-1/${size}`); + expect(yield* Effect.promise(() => response.text())).toBe("01"); + } else { + expect(yield* Effect.promise(() => response.text())).toBe( + "File is too large to preview.", + ); + } + } + }).pipe(Effect.provide(fileResponseLayer)), + ); + + it.effect("streams guarded file ranges, including suffixes and conditional requests", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-guarded-range-" }); + const filePath = path.join(directory, "clip.mp4"); + yield* fs.writeFileString(filePath, "0123456789"); + const canonicalPath = yield* fs.realPath(filePath); + for (const [range, ifRange, expected, status, contentRange] of [ + [undefined, undefined, "0123456789", 200, null], + ["bytes=0-1", undefined, "01", 206, "bytes 0-1/10"], + ["bytes=4-", undefined, "456789", 206, "bytes 4-9/10"], + ["bytes=-3", undefined, "789", 206, "bytes 7-9/10"], + ["bytes=-999999999999999999999999", undefined, "0123456789", 206, "bytes 0-9/10"], + ["bytes=10-", undefined, "", 416, "bytes */10"], + ["bytes=0-1", '"old-etag"', "0123456789", 200, null], + ["bytes=0-1", "", "0123456789", 200, null], + ] as const) { + const file = yield* openMediaFile(canonicalPath); + if (!file) throw new Error("Expected an opened media file"); + const response = HttpServerResponse.toWeb( + yield* assetFileResponse( + { path: canonicalPath, file, mimeType: "video/mp4" }, + range, + ifRange, + ), + ); + expect(response.status).toBe(status); + expect(response.headers.get("accept-ranges")).toBe("bytes"); + expect(response.headers.get("content-range")).toBe(contentRange); + expect(response.headers.get("cache-control")).toBe("private, no-store"); + expect(response.headers.get("etag")).toBeNull(); + expect(response.headers.get("last-modified")).toBeNull(); + if (status !== 416) + expect(response.headers.get("content-length")).toBe(String(expected.length)); + expect(yield* Effect.promise(() => response.text())).toBe(expected); + } + }).pipe(Effect.provide(fileResponseLayer)), + ); + + it.effect("closes guarded descriptors after full, HEAD, rejected, and cancelled responses", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-guarded-cleanup-" }); + const filePath = path.join(directory, "clip.mp4"); + const bytes = new Uint8Array(1024 * 1024).fill(42); + yield* fs.writeFile(filePath, bytes); + const canonicalPath = yield* fs.realPath(filePath); + for (const mode of ["full", "HEAD", "rejected", "cancelled"] as const) { + const file = yield* Effect.scoped( + Effect.gen(function* () { + const file = yield* openMediaFile(canonicalPath); + if (!file) throw new Error("Expected an opened media file"); + const response = HttpServerResponse.toWeb( + yield* assetFileResponse( + { path: canonicalPath, file, mimeType: "video/mp4" }, + mode === "rejected" ? `bytes=${bytes.length}-` : "bytes=0-", + undefined, + mode === "HEAD" ? "HEAD" : "GET", + ), + ); + if (mode === "HEAD") { + expect(response.status).toBe(200); + expect(response.headers.get("content-length")).toBe(String(bytes.length)); + expect(response.headers.get("content-range")).toBeNull(); + expect(yield* Effect.promise(() => response.text())).toBe(""); + } else if (mode === "rejected") { + expect(response.status).toBe(416); + expect(yield* Effect.promise(() => response.text())).toBe(""); + } else if (mode === "cancelled") { + const reader = response.body!.getReader(); + const first = yield* Effect.promise(() => reader.read()); + expect(first.done).toBe(false); + expect(first.value!.byteLength).toBeLessThan(bytes.length); + yield* Effect.promise(() => reader.cancel()); + } else { + expect(yield* Effect.promise(() => response.arrayBuffer())).toEqual(bytes.buffer); + } + return file; + }), + ); + expect(file.handle.fd).toBe(-1); + } + }).pipe(Effect.provide(fileResponseLayer)), + ); + + it.effect("streams exactly the requested bytes and leaves full downloads intact", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-video-range-" }); + const file = path.join(directory, "clip.mp4"); + yield* fs.writeFileString(file, "0123456789"); + const asset = { path: file, mimeType: "video/mp4" }; + for (const [header, expected, contentRange] of [ + ["bytes=0-1", "01", "bytes 0-1/10"], + ["bytes=4-", "456789", "bytes 4-9/10"], + ["bytes=-3", "789", "bytes 7-9/10"], + ["bytes=-999999999999999999999999", "0123456789", "bytes 0-9/10"], + ["bytes=8-999999999999999999999999", "89", "bytes 8-9/10"], + ] as const) { + const response = HttpServerResponse.toWeb(yield* assetFileResponse(asset, header)); + expect(response.status).toBe(206); + expect(response.headers.get("accept-ranges")).toBe("bytes"); + expect(response.headers.get("content-range")).toBe(contentRange); + expect(response.headers.get("content-length")).toBe(String(expected.length)); + expect(yield* Effect.promise(() => response.text())).toBe(expected); + } + for (const header of [ + undefined, + "items=0-1", + "bytes=0-1,4-5", + "bytes=8-2", + "bytes=-", + "bytes=bad", + ]) { + const response = HttpServerResponse.toWeb(yield* assetFileResponse(asset, header)); + expect(response.status).toBe(200); + expect(yield* Effect.promise(() => response.text())).toBe("0123456789"); + } + const conditional = HttpServerResponse.toWeb( + yield* assetFileResponse(asset, "bytes=0-1", '"old-etag"'), + ); + expect(conditional.status).toBe(200); + expect(yield* Effect.promise(() => conditional.text())).toBe("0123456789"); + const uppercase = HttpServerResponse.toWeb( + yield* assetFileResponse({ ...asset, mimeType: "Video/MP4" }, "bytes=0-1"), + ); + expect(uppercase.status).toBe(206); + expect(yield* Effect.promise(() => uppercase.text())).toBe("01"); + const image = HttpServerResponse.toWeb( + yield* assetFileResponse({ path: file, mimeType: "image/png" }, "bytes=0-1"), + ); + expect(image.status).toBe(200); + expect(image.headers.has("accept-ranges")).toBe(false); + expect(yield* Effect.promise(() => image.text())).toBe("0123456789"); + }).pipe(Effect.provide(fileResponseLayer)), + ); + + it.effect("rejects ranges outside the file, including empty files", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-video-range-" }); + const file = path.join(directory, "clip.mp4"); + yield* fs.writeFileString(file, "0123456789"); + for (const header of ["bytes=10-", "bytes=-0", "bytes=999999999999999999999999-"]) { + const response = HttpServerResponse.toWeb( + yield* assetFileResponse({ path: file, mimeType: "video/mp4" }, header), + ); + expect(response.status).toBe(416); + expect(response.headers.get("content-range")).toBe("bytes */10"); + expect(yield* Effect.promise(() => response.text())).toBe(""); + } + yield* fs.writeFileString(file, ""); + const empty = HttpServerResponse.toWeb( + yield* assetFileResponse({ path: file, mimeType: "video/mp4" }, "bytes=0-1"), + ); + expect(empty.status).toBe(416); + expect(empty.headers.get("content-range")).toBe("bytes */0"); + }).pipe(Effect.provide(fileResponseLayer)), + ); +}); describe("http dev routing", () => { it("treats localhost and loopback addresses as local", () => { @@ -45,6 +313,17 @@ describe("assetResponseHeaders", () => { }); }); + it("serves inline videos with their declared mime type", () => { + expect( + assetResponseHeaders("/attachments/demo.bin", { + mimeType: 'video/mp4; codecs="avc1.42E01E"', + }), + ).toEqual({ + "Cache-Control": "private, max-age=3600", + "Content-Type": "video/mp4", + "X-Content-Type-Options": "nosniff", + }); + }); it("declares utf-8 for HTML assets so non-ASCII content renders correctly", () => { expect(assetResponseHeaders("/workspace/page.html")).toHaveProperty( "Content-Type", @@ -55,4 +334,79 @@ describe("assetResponseHeaders", () => { "text/html; charset=utf-8", ); }); + + it("downloads uploaded documents without executing their content", () => { + expect(assetResponseHeaders("/attachments/upload.html", { download: true })).toMatchObject({ + "Content-Disposition": "attachment", + "Content-Security-Policy": "default-src 'none'; sandbox", + "Content-Type": "application/octet-stream", + }); + }); + + it("serves the real filename and mime type when the claims carry them", () => { + expect( + assetResponseHeaders("/attachments/thread-1-abc-pdf.pdf", { + download: true, + fileName: "Q3 report.pdf", + mimeType: "application/pdf", + }), + ).toMatchObject({ + "Content-Disposition": 'attachment; filename="Q3 report.pdf"', + "Content-Security-Policy": "default-src 'none'; sandbox", + "Content-Type": "application/pdf", + }); + }); + + it("keeps renderable mime types as octet-stream downloads", () => { + for (const mimeType of [ + "text/html", + "text/xml", + "image/svg+xml", + "application/xhtml+xml", + "application/rss+xml", + "APPLICATION/XML", + "IMAGE/SVG+XML", + "application/xml-dtd", + "application/xml-external-parsed-entity", + "not a mime", + ]) { + expect( + assetResponseHeaders("/attachments/upload.bin", { download: true, mimeType }), + ).toHaveProperty("Content-Type", "application/octet-stream"); + } + }); + + it("preserves official Office Open XML mime types", () => { + for (const mimeType of [ + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "application/vnd.openxmlformats-officedocument.presentationml.presentation", + ]) { + expect( + assetResponseHeaders("/attachments/upload.bin", { download: true, mimeType }), + ).toHaveProperty("Content-Type", mimeType); + } + }); +}); + +describe("downloadContentDisposition", () => { + it("quotes plain names and strips quotes and control characters", () => { + expect(downloadContentDisposition("report.pdf")).toBe('attachment; filename="report.pdf"'); + expect(downloadContentDisposition('we"ird\n.pdf')).toBe('attachment; filename="we_ird_.pdf"'); + }); + + it("adds an RFC 5987 encoded name for non-ASCII filenames", () => { + expect(downloadContentDisposition("répört.pdf")).toBe( + `attachment; filename="r_p_rt.pdf"; filename*=UTF-8''r%C3%A9p%C3%B6rt.pdf`, + ); + expect(downloadContentDisposition("résumé'(*).pdf")).toBe( + `attachment; filename="r_sum_'(*).pdf"; filename*=UTF-8''r%C3%A9sum%C3%A9%27%28%2A%29.pdf`, + ); + }); + + it("does not throw on unpaired surrogates in the filename", () => { + expect(downloadContentDisposition("bad\ud800name.pdf")).toBe( + `attachment; filename="bad_name.pdf"; filename*=UTF-8''bad%EF%BF%BDname.pdf`, + ); + }); }); diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index c3104e7bc420..8b7c2ad3a61b 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -12,6 +12,7 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; +import * as Stream from "effect/Stream"; import { cast } from "effect/Function"; import { HttpBody, @@ -28,6 +29,7 @@ import { OtlpTracer } from "effect/unstable/observability"; import * as ServerConfig from "./config.ts"; import { ASSET_ROUTE_PREFIX, resolveAsset } from "./assets/AssetAccess.ts"; +import { statMediaFile, streamMediaFile, type OpenMediaFile } from "./assets/MediaFile.ts"; import { ATTACHMENT_UPLOAD_ROUTE_PREFIX, storeAttachmentUpload, @@ -50,20 +52,158 @@ const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]); const DESKTOP_RENDERER_ORIGINS = ["t3code://app", "t3code-dev://app"]; const SVG_CONTENT_SECURITY_POLICY = "default-src 'none'; style-src 'unsafe-inline'; sandbox"; -export function assetResponseHeaders(filePath: string): Record { +// Types a browser may render as a document if a proxy strips the disposition +// header. Downloads of these fall back to octet-stream. +const DOWNLOAD_MIME_TYPE_PATTERN = /^[\w!#$&^.+-]+\/[\w!#$&^.+-]+$/; +const isSafeDownloadMimeType = (mimeType: string): boolean => + DOWNLOAD_MIME_TYPE_PATTERN.test(mimeType) && + !/(?:^text\/html$|\/xml(?:$|-)|\+xml$)/i.test(mimeType.trim().toLowerCase()); +const isSafeInlineVideoMimeType = (mimeType: string): boolean => + DOWNLOAD_MIME_TYPE_PATTERN.test(mimeType) && mimeType.toLowerCase().startsWith("video/"); + +/** RFC 6266 disposition with an ASCII fallback name plus a UTF-8 `filename*`. */ +export function downloadContentDisposition(fileName?: string): string { + if (fileName === undefined) { + return "attachment"; + } + // toWellFormed: encodeURIComponent throws URIError on unpaired surrogates. + // eslint-disable-next-line no-control-regex -- Header filenames must strip ASCII controls. + const sanitized = fileName.toWellFormed().replace(/[\u0000-\u001f"\\]/g, "_"); + const asciiFallback = sanitized.replace(/[^\u0020-\u007e]/g, "_"); + const needsExtended = asciiFallback !== sanitized; + const extendedName = encodeURIComponent(sanitized).replace( + /['()*]/g, + (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`, + ); + return `attachment; filename="${asciiFallback}"${ + needsExtended ? `; filename*=UTF-8''${extendedName}` : "" + }`; +} + +export function assetResponseHeaders( + filePath: string, + options?: { + readonly download?: boolean; + readonly fileName?: string; + readonly mimeType?: string; + }, +): Record { const lowerPath = filePath.toLowerCase(); + const inlineVideoMimeType = options?.mimeType?.split(";", 1)[0]?.trim(); return { "Cache-Control": "private, max-age=3600", "X-Content-Type-Options": "nosniff", - ...(lowerPath.endsWith(".html") || lowerPath.endsWith(".htm") - ? { "Content-Type": "text/html; charset=utf-8" } - : {}), - ...(lowerPath.endsWith(".svg") + ...(options?.download + ? { + "Content-Disposition": downloadContentDisposition(options.fileName), + "Content-Security-Policy": "default-src 'none'; sandbox", + "Content-Type": + options.mimeType !== undefined && isSafeDownloadMimeType(options.mimeType) + ? options.mimeType + : "application/octet-stream", + } + : inlineVideoMimeType !== undefined && isSafeInlineVideoMimeType(inlineVideoMimeType) + ? { "Content-Type": inlineVideoMimeType } + : lowerPath.endsWith(".html") || lowerPath.endsWith(".htm") + ? { "Content-Type": "text/html; charset=utf-8" } + : {}), + ...(!options?.download && lowerPath.endsWith(".svg") ? { "Content-Security-Policy": SVG_CONTENT_SECURITY_POLICY } : {}), }; } +/** A single byte range for native video readers; unsupported range syntax uses the full file. */ +function assetByteRange(header: string, size: bigint) { + const match = /^bytes=(\d*)-(\d*)$/i.exec(header.trim()); + if (!match || (!match[1] && !match[2])) return null; + const first = match[1] ? BigInt(match[1]) : null; + const last = match[2] ? BigInt(match[2]) : null; + if (first !== null && last !== null && last < first) return null; + if (size === 0n || (first !== null && first >= size) || (first === null && last === 0n)) { + return { _tag: "Unsatisfiable" as const }; + } + const start = first ?? (last! >= size ? 0n : size - last!); + const end = first === null || last === null || last >= size ? size - 1n : last; + if (!Number.isSafeInteger(Number(start)) || !Number.isSafeInteger(Number(end))) { + return { _tag: "Unsatisfiable" as const }; + } + return { + _tag: "Range" as const, + offset: start, + bytesToRead: end - start + 1n, + contentRange: `bytes ${start}-${end}/${size}`, + }; +} + +export const assetFileResponse = Effect.fn("assetFileResponse")(function* ( + asset: { + readonly path: string; + readonly download?: boolean; + readonly fileName?: string; + readonly mimeType?: string; + readonly file?: OpenMediaFile; + }, + rangeHeader?: string, + ifRangeHeader?: string, + method: "GET" | "HEAD" = "GET", +) { + const headers = assetResponseHeaders(asset.path, asset); + const mediaFile = asset.file; + const mediaInfo = mediaFile ? yield* statMediaFile(asset.path, mediaFile) : undefined; + const isVideo = headers["Content-Type"]?.toLowerCase().startsWith("video/") === true; + if (mediaFile && isVideo) { + // Host videos can change in place. Do not invite conditional range requests + // with validators that cannot establish byte-for-byte identity. + headers["Cache-Control"] = "private, no-store"; + } + let status = 200; + let offset = 0n; + let bytesToRead: bigint | undefined; + if (isVideo) { + headers["Accept-Ranges"] = "bytes"; + // If-Range requires a matching validator. A full response is safe when we cannot validate it. + if (method === "GET" && rangeHeader && ifRangeHeader === undefined) { + const fs = yield* FileSystem.FileSystem; + const info = mediaInfo ?? (yield* fs.stat(asset.path)); + const range = assetByteRange(rangeHeader, info.size); + if (range?._tag === "Unsatisfiable") { + return HttpServerResponse.empty({ + status: 416, + headers: { ...headers, "Content-Range": `bytes */${info.size}` }, + }); + } + if (range?._tag === "Range") { + status = 206; + offset = range.offset; + bytesToRead = range.bytesToRead; + headers["Content-Range"] = range.contentRange; + } + } + } + if (mediaFile && mediaInfo) { + const size = bytesToRead ?? mediaInfo.size; + headers["Content-Type"] ??= Mime.getType(asset.path) ?? "application/octet-stream"; + headers["Content-Length"] = String(size); + if (!isVideo) { + headers["Last-Modified"] = mediaInfo.mtime.toUTCString(); + headers.ETag = `W/"${mediaInfo.size.toString(16)}-${mediaInfo.mtimeMs.toString(16)}"`; + } + if (method === "HEAD" || size === 0n) { + return HttpServerResponse.empty({ status, headers }); + } + const body = streamMediaFile(mediaFile, offset, size); + if (!body) { + return HttpServerResponse.text("File is too large to preview.", { status: 413 }); + } + return HttpServerResponse.stream(body, { + status, + headers, + }); + } + return yield* HttpServerResponse.file(asset.path, { status, offset, bytesToRead, headers }); +}); + export const httpCompressionLayer = HttpRouter.middleware(HttpMiddleware.compression(), { global: true, }); @@ -117,7 +257,10 @@ const authenticateRawRouteWithScope = ( const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; const session = yield* serverAuth.authenticateHttpRequest(request).pipe( Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) => - failEnvironmentAuthInvalid(EnvironmentAuth.serverAuthCredentialReason(error)), + failEnvironmentAuthInvalid( + EnvironmentAuth.serverAuthCredentialReason(error), + EnvironmentAuth.serverAuthDpopFailureReason(error), + ), ), Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => failEnvironmentInternal("internal_error", error), @@ -226,10 +369,12 @@ export const assetRouteLayer = HttpRouter.add( if (!asset) { return HttpServerResponse.text("Not Found", { status: 404 }); } - return yield* HttpServerResponse.file(asset.path, { - status: 200, - headers: assetResponseHeaders(asset.path), - }).pipe( + return yield* assetFileResponse( + asset, + request.method === "GET" ? request.headers.range : undefined, + request.headers["if-range"], + request.method === "HEAD" ? "HEAD" : "GET", + ).pipe( Effect.orElseSucceed(() => HttpServerResponse.text("Internal Server Error", { status: 500 })), ); }), @@ -265,15 +410,9 @@ export const attachmentUploadRouteLayer = HttpRouter.add( }); } - const body = yield* request.arrayBuffer.pipe( - Effect.provideService(HttpServerRequest.MaxBodySize, FileSystem.Size(claims.sizeBytes)), - Effect.orElseSucceed(() => null), - ); - if (body === null) { - return HttpServerResponse.text("Failed to read the upload body.", { status: 400 }); - } - - const stored = yield* storeAttachmentUpload(claims, new Uint8Array(body)); + // Keep the request stream in the route scope until the response is sent. + const bodyPull = yield* Stream.toPull(request.stream); + const stored = yield* storeAttachmentUpload(claims, Stream.fromPull(Effect.succeed(bodyPull))); return stored.ok ? HttpServerResponse.empty({ status: 204 }) : HttpServerResponse.text(stored.detail, { status: stored.status }); diff --git a/apps/server/src/httpCors.ts b/apps/server/src/httpCors.ts index aeb8dbce5a52..3fdc165bab77 100644 --- a/apps/server/src/httpCors.ts +++ b/apps/server/src/httpCors.ts @@ -6,9 +6,3 @@ export const browserApiCorsAllowedHeaders = [ "content-type", "dpop", ] as const; - -export const browserApiCorsHeaders = { - "access-control-allow-origin": "*", - "access-control-allow-methods": browserApiCorsAllowedMethods.join(", "), - "access-control-allow-headers": browserApiCorsAllowedHeaders.join(", "), -} as const; diff --git a/apps/server/src/keybindings.test.ts b/apps/server/src/keybindings.test.ts index 24a137d933fa..079e7a14edac 100644 --- a/apps/server/src/keybindings.test.ts +++ b/apps/server/src/keybindings.test.ts @@ -195,6 +195,9 @@ it.layer(NodeServices.layer)("keybindings", (it) => { assert.equal(defaultsByCommand.get("thread.previous"), "mod+shift+["); assert.equal(defaultsByCommand.get("thread.next"), "mod+shift+]"); + assert.equal(defaultsByCommand.get("thread.copyReference"), "mod+shift+c"); + assert.equal(defaultsByCommand.get("thread.settle"), "mod+shift+s"); + assert.equal(defaultsByCommand.get("thread.pin"), "mod+shift+p"); assert.equal(defaultsByCommand.get("thread.jump.1"), "mod+1"); assert.equal(defaultsByCommand.get("thread.jump.9"), "mod+9"); assert.equal(defaultsByCommand.get("modelPicker.toggle"), "mod+shift+m"); diff --git a/apps/server/src/keybindings.ts b/apps/server/src/keybindings.ts index 10d98bf64290..1795808bc179 100644 --- a/apps/server/src/keybindings.ts +++ b/apps/server/src/keybindings.ts @@ -96,10 +96,6 @@ export const ResolvedKeybindingFromConfig = KeybindingRule.pipe( ), ); -export const ResolvedKeybindingsFromConfig = Schema.Array(ResolvedKeybindingFromConfig).check( - Schema.isMaxLength(MAX_KEYBINDINGS_COUNT), -); - function isSameKeybindingRule(left: KeybindingRule, right: KeybindingRule): boolean { return ( left.command === right.command && diff --git a/apps/server/src/mcp/toolkits/preview/tools.ts b/apps/server/src/mcp/toolkits/preview/tools.ts index 3baf56a7962a..33528d8bb38c 100644 --- a/apps/server/src/mcp/toolkits/preview/tools.ts +++ b/apps/server/src/mcp/toolkits/preview/tools.ts @@ -33,12 +33,15 @@ const PreviewActionResult = Schema.Record(Schema.String, Schema.Never).annotate( description: "The preview action completed successfully.", }); +/** Drives the real browser and can destroy page state. */ const browserTool = (tool: T): T => tool.annotate(Tool.OpenWorld, true).annotate(Tool.Destructive, true) as T; +/** Same open-world browser access, but the action does not destroy page state. */ const safeBrowserTool = (tool: T): T => - browserTool(tool).annotate(Tool.Destructive, false) as T; + tool.annotate(Tool.OpenWorld, true).annotate(Tool.Destructive, false) as T; +/** A safe browser action that only observes, so it is also repeatable. */ const readonlyBrowserTool = (tool: T): T => safeBrowserTool(tool).annotate(Tool.Readonly, true).annotate(Tool.Idempotent, true) as T; diff --git a/apps/server/src/observability/Attributes.test.ts b/apps/server/src/observability/Attributes.test.ts deleted file mode 100644 index d9ed2e1271f6..000000000000 --- a/apps/server/src/observability/Attributes.test.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { assert, describe, it } from "@effect/vitest"; - -import { normalizeModelMetricLabel } from "./Attributes.ts"; - -describe("Attributes", () => { - it("groups GPT-family models under a shared metric label", () => { - assert.strictEqual(normalizeModelMetricLabel("gpt-4o"), "gpt"); - assert.strictEqual(normalizeModelMetricLabel("gpt-5.4"), "gpt"); - assert.strictEqual(normalizeModelMetricLabel("claude-sonnet-4"), "claude"); - }); -}); diff --git a/apps/server/src/orchestration/ActivityPayloadProjection.test.ts b/apps/server/src/orchestration/ActivityPayloadProjection.test.ts index 2cdfef19fd18..18732fa4ea37 100644 --- a/apps/server/src/orchestration/ActivityPayloadProjection.test.ts +++ b/apps/server/src/orchestration/ActivityPayloadProjection.test.ts @@ -64,6 +64,28 @@ describe("projectActivityPayload", () => { expect(JSON.stringify(projected.payload).length).toBeLessThan(500); }); + it("keeps preview normalization and fence-only fallback while scanning lines", () => { + const preview = projectActivityPayload( + activity({ + itemType: "command_execution", + data: { rawOutput: `\`\`\`\n actual\tresult \n${"x".repeat(5000)}` }, + }), + ); + const fences = projectActivityPayload( + activity({ + itemType: "command_execution", + data: { rawOutput: "```\r\n \t \n```\n" }, + }), + ); + + expect((preview.payload as { data: { rawOutput: unknown } }).data.rawOutput).toEqual({ + content: "actual result", + }); + expect((fences.payload as { data: { rawOutput: unknown } }).data.rawOutput).toEqual({ + content: "2 lines", + }); + }); + it("keeps bounded Claude and ACP command output summaries", () => { const claude = projectActivityPayload( activity({ diff --git a/apps/server/src/orchestration/ActivityPayloadProjection.ts b/apps/server/src/orchestration/ActivityPayloadProjection.ts index 32f249c251d5..0b1cb15d3dbc 100644 --- a/apps/server/src/orchestration/ActivityPayloadProjection.ts +++ b/apps/server/src/orchestration/ActivityPayloadProjection.ts @@ -144,22 +144,29 @@ function projectCommandValue(data: Record): unknown { } function summarizeToolTextOutput(value: string): string | null { - const lines: string[] = []; - for (const rawLine of value.split(/\r?\n/u)) { - const line = rawLine.replace(/\s+/g, " ").trim(); + let meaningfulLineCount = 0; + let offset = 0; + + while (offset <= value.length) { + const newlineIndex = value.indexOf("\n", offset); + const lineEnd = newlineIndex === -1 ? value.length : newlineIndex; + const line = value.slice(offset, lineEnd).replace(/\s+/g, " ").trim(); if (line.length > 0) { - lines.push(line); + meaningfulLineCount += 1; + if (line !== "```") { + const summary = line.length <= 84 ? line : `${line.slice(0, 83).trimEnd()}…`; + // V8 can retain the full tool output behind a short sliced string. + // Join a tiny character array so the returned preview owns its bytes. + return Array.from(summary).join(""); + } } + if (newlineIndex === -1) { + break; + } + offset = newlineIndex + 1; } - const firstLine = lines.find((line) => line !== "```"); - if (firstLine) { - return firstLine.length <= 84 ? firstLine : `${firstLine.slice(0, 83).trimEnd()}…`; - } - if (lines.length > 1) { - return `${lines.length.toLocaleString()} lines`; - } - return null; + return meaningfulLineCount > 1 ? `${meaningfulLineCount.toLocaleString()} lines` : null; } /** @@ -488,9 +495,6 @@ function toolLifecycleIdentity(activity: OrchestrationThreadActivity): string | * update within the turn — a later update belongs to a subsequent call that * reuses the same identity and is still in flight. Rows without a lifecycle * identity pass through, matching the clients, which never collapse them. - * Live `thread.activity-appended` events are untouched: updates still stream - * in real time and the completion supersedes them on the client as before. - * * Deliberate divergence from client collapse: clients fold only *adjacent* * lifecycle rows, so a superseded update separated from its completion by an * interleaved parallel call renders as its own row today, and this drop @@ -517,7 +521,7 @@ function dropSupersededToolUpdatedActivities( if (!identity) { continue; } - const key = `${activity.turnId ?? ""}${identity}`; + const key = `${activity.turnId ?? ""}\u0000${identity}`; const indices = completionIndicesByKey.get(key); if (indices) { indices.push(index); @@ -537,7 +541,7 @@ function dropSupersededToolUpdatedActivities( if (!identity) { return true; } - const indices = completionIndicesByKey.get(`${activity.turnId ?? ""}${identity}`); + const indices = completionIndicesByKey.get(`${activity.turnId ?? ""}\u0000${identity}`); return !indices?.some((completionIndex) => completionIndex > index); }); } diff --git a/apps/server/src/orchestration/Errors.ts b/apps/server/src/orchestration/Errors.ts index 7abd567704f1..dc29dcbfa6f8 100644 --- a/apps/server/src/orchestration/Errors.ts +++ b/apps/server/src/orchestration/Errors.ts @@ -1,3 +1,4 @@ +import { ThreadId } from "@t3tools/contracts"; import * as SchemaIssue from "effect/SchemaIssue"; import * as Schema from "effect/Schema"; @@ -40,6 +41,24 @@ export class OrchestrationCommandInvariantError extends Schema.TaggedErrorClass< } } +export class OrchestrationThreadSettleBlockedError extends Schema.TaggedErrorClass()( + "OrchestrationThreadSettleBlockedError", + { + threadId: ThreadId, + }, +) { + override get message(): string { + return "This thread still needs attention. Resolve or interrupt it first, then try again."; + } +} + +export const OrchestrationCommandRejection = Schema.Union([ + OrchestrationCommandInvariantError, + OrchestrationThreadSettleBlockedError, +]); +export type OrchestrationCommandRejection = typeof OrchestrationCommandRejection.Type; +export const isOrchestrationCommandRejection = Schema.is(OrchestrationCommandRejection); + export class OrchestrationCommandPreviouslyRejectedError extends Schema.TaggedErrorClass()( "OrchestrationCommandPreviouslyRejectedError", { @@ -96,7 +115,7 @@ export class OrchestrationListenerCallbackError extends Schema.TaggedErrorClass< export type OrchestrationDispatchError = | ProjectionRepositoryError - | OrchestrationCommandInvariantError + | OrchestrationCommandRejection | OrchestrationCommandIdConflictError | OrchestrationCommandPreviouslyRejectedError | OrchestrationProjectorDecodeError diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.ts index 95adee0cf7f8..dd9d397200e7 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.ts @@ -164,7 +164,7 @@ const make = Effect.gen(function* () { const resolveThreadDetail = Effect.fn("resolveThreadDetail")(function* (threadId: ThreadId) { return yield* projectionSnapshotQuery - .getThreadDetailById(threadId) + .getThreadDetailById(threadId, { activityKinds: [] }) .pipe(Effect.map(Option.getOrUndefined)); }); diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index 382c253fe60b..3952cf34bddb 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -10,6 +10,7 @@ import { ProviderInstanceId, } from "@t3tools/contracts"; import * as NodeServices from "@effect/platform-node/NodeServices"; +import { it as effectIt } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as ManagedRuntime from "effect/ManagedRuntime"; @@ -17,10 +18,12 @@ import * as Metric from "effect/Metric"; import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; import * as Stream from "effect/Stream"; +import { TestClock } from "effect/testing"; import { describe, expect, it } from "vite-plus/test"; import { PersistenceSqlError } from "../../persistence/Errors.ts"; import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts"; +import * as OrchestrationCommandReceipts from "../../persistence/Services/OrchestrationCommandReceipts.ts"; import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts"; import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; import { @@ -46,27 +49,30 @@ const asMessageId = (value: string): MessageId => MessageId.make(value); const asTurnId = (value: string): TurnId => TurnId.make(value); const asCheckpointRef = (value: string): CheckpointRef => CheckpointRef.make(value); -async function createOrchestrationSystem() { +function makeOrchestrationLayer() { const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { prefix: "t3-orchestration-engine-test-", }); - const orchestrationLayer = Layer.mergeAll( + return Layer.mergeAll( OrchestrationEngineLive.pipe( Layer.provide(OrchestrationProjectionSnapshotQueryLive), Layer.provide(OrchestrationProjectionPipelineLive), ), OrchestrationProjectionSnapshotQueryLive, ).pipe( - Layer.provide(ThreadBackgroundLiveness.layer), + Layer.provideMerge(ThreadBackgroundLiveness.layer), Layer.provide(ThreadPlanProgress.layer), Layer.provide(OrchestrationEventStoreLive), - Layer.provide(OrchestrationCommandReceiptRepositoryLive), + Layer.provideMerge(OrchestrationCommandReceiptRepositoryLive), Layer.provide(RepositoryIdentityResolver.layer), Layer.provide(SqlitePersistenceMemory), Layer.provideMerge(ServerConfigLayer), Layer.provideMerge(NodeServices.layer), ); - const runtime = ManagedRuntime.make(orchestrationLayer); +} + +async function createOrchestrationSystem() { + const runtime = ManagedRuntime.make(makeOrchestrationLayer()); const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService)); const snapshotQuery = await runtime.runPromise(Effect.service(ProjectionSnapshotQuery)); return { @@ -113,6 +119,7 @@ describe("OrchestrationEngine", () => { detail: "historical replay should not be used during bootstrap", }), ), + hasEventAfter: () => Effect.succeed(false), }; const projectionSnapshot = { @@ -217,6 +224,7 @@ describe("OrchestrationEngine", () => { } satisfies OrchestrationProjectionPipelineShape), ), Layer.provide(Layer.succeed(OrchestrationEventStore, eventStore)), + Layer.provide(ThreadBackgroundLiveness.layer), Layer.provide(OrchestrationCommandReceiptRepositoryLive), Layer.provide(SqlitePersistenceMemory), Layer.provideMerge(NodeServices.layer), @@ -242,6 +250,205 @@ describe("OrchestrationEngine", () => { await runtime.dispose(); }); + effectIt.effect("preserves the blocked-settle error and persists its rejected receipt", () => + Effect.gen(function* () { + const engine = yield* OrchestrationEngineService; + const receipts = yield* OrchestrationCommandReceipts.OrchestrationCommandReceiptRepository; + const projectId = ProjectId.make("project-blocked-settle"); + const threadId = ThreadId.make("thread-blocked-settle"); + const commandId = CommandId.make("cmd-blocked-settle"); + const createdAt = now(); + + yield* engine.dispatch({ + type: "project.create", + commandId: CommandId.make("cmd-blocked-settle-project-create"), + projectId, + title: "Project", + workspaceRoot: "/tmp/project-blocked-settle", + createdAt, + }); + yield* engine.dispatch({ + type: "thread.create", + commandId: CommandId.make("cmd-blocked-settle-thread-create"), + threadId, + projectId, + title: "Thread", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt, + }); + yield* engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-blocked-settle-session-set"), + threadId, + createdAt, + session: { + threadId, + status: "running", + providerName: "codex", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: createdAt, + }, + }); + + const sequence = yield* engine.latestSequence; + const error = yield* engine + .dispatch({ type: "thread.settle", commandId, threadId }) + .pipe(Effect.flip); + const message = + "This thread still needs attention. Resolve or interrupt it first, then try again."; + expect(error).toMatchObject({ + _tag: "OrchestrationThreadSettleBlockedError", + threadId, + message, + }); + expect(Option.getOrNull(yield* receipts.getByCommandId({ commandId }))).toMatchObject({ + commandId, + aggregateKind: "thread", + aggregateId: threadId, + status: "rejected", + error: message, + resultSequence: sequence, + }); + expect(yield* engine.latestSequence).toBe(sequence); + }).pipe(Effect.provide(makeOrchestrationLayer())), + ); + + effectIt.effect( + "rejects persisted changes and live background work without blocking unrelated threads", + () => + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(now())); + const engine = yield* OrchestrationEngineService; + const snapshots = yield* ProjectionSnapshotQuery; + const backgroundLiveness = yield* ThreadBackgroundLiveness.ThreadBackgroundLivenessService; + const projectId = ProjectId.make("project-auto-settle-guard"); + const guardedThreadId = ThreadId.make("thread-auto-settle-guarded"); + const unrelatedThreadId = ThreadId.make("thread-auto-settle-unrelated"); + const liveThreadId = ThreadId.make("thread-auto-settle-live"); + + yield* engine.dispatch({ + type: "project.create", + commandId: CommandId.make("cmd-auto-settle-guard-project"), + projectId, + title: "Project", + workspaceRoot: "/tmp/project-auto-settle-guard", + createdAt: now(), + }); + for (const threadId of [guardedThreadId, unrelatedThreadId, liveThreadId]) { + yield* engine.dispatch({ + type: "thread.create", + commandId: CommandId.make(`cmd-create-${threadId}`), + threadId, + projectId, + title: "Thread", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt: now(), + }); + } + + const beforeUpdate = yield* snapshots.getSnapshot(); + const snapshotSequence = beforeUpdate.snapshotSequence; + const originalUpdatedAt = beforeUpdate.threads.find( + (thread) => thread.id === guardedThreadId, + )?.updatedAt; + yield* engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("cmd-auto-settle-guard-meta"), + threadId: guardedThreadId, + branch: "new-branch", + }); + const afterUpdate = yield* snapshots.getSnapshot(); + expect(afterUpdate.threads.find((thread) => thread.id === guardedThreadId)?.updatedAt).toBe( + originalUpdatedAt, + ); + + const staleError = yield* engine + .dispatch({ + type: "thread.auto-settle", + commandId: CommandId.make("cmd-auto-settle-stale-snapshot"), + threadId: guardedThreadId, + snapshotSequence, + }) + .pipe(Effect.flip); + expect(staleError._tag).toBe("OrchestrationCommandInvariantError"); + + const livenessSnapshotSequence = yield* engine.latestSequence; + for (const [taskType, expectedLiveness] of [ + ["subagent", "working"], + ["local_bash", "monitoring"], + ] as const) { + backgroundLiveness.recordTaskLiveness({ + threadId: liveThreadId, + taskId: `task-${expectedLiveness}`, + taskType, + status: undefined, + kind: "started", + }); + expect(backgroundLiveness.getThreadBackgroundLiveness(liveThreadId)).toBe( + expectedLiveness, + ); + expect(yield* engine.latestSequence).toBe(livenessSnapshotSequence); + + const livenessError = yield* engine + .dispatch({ + type: "thread.auto-settle", + commandId: CommandId.make(`cmd-auto-settle-${expectedLiveness}`), + threadId: liveThreadId, + snapshotSequence: livenessSnapshotSequence, + }) + .pipe(Effect.flip); + expect(livenessError._tag).toBe("OrchestrationCommandInvariantError"); + expect(yield* engine.latestSequence).toBe(livenessSnapshotSequence); + backgroundLiveness.clearThreadLiveness(liveThreadId); + } + + yield* engine.dispatch({ + type: "thread.auto-settle", + commandId: CommandId.make("cmd-auto-settle-after-liveness-cleared"), + threadId: liveThreadId, + snapshotSequence: livenessSnapshotSequence, + }); + + const freshSnapshotSequence = yield* engine.latestSequence; + yield* engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("cmd-auto-settle-unrelated-meta"), + threadId: unrelatedThreadId, + title: "Unrelated update", + }); + yield* engine.dispatch({ + type: "thread.auto-settle", + commandId: CommandId.make("cmd-auto-settle-after-unrelated-update"), + threadId: guardedThreadId, + snapshotSequence: freshSnapshotSequence, + }); + + const settled = yield* snapshots.getSnapshot(); + expect( + settled.threads.find((thread) => thread.id === guardedThreadId)?.settledOverride, + ).toBe("settled"); + expect(settled.threads.find((thread) => thread.id === liveThreadId)?.settledOverride).toBe( + "settled", + ); + }).pipe(Effect.provide(makeOrchestrationLayer())), + ); + it("persists deterministic read models for repeated snapshot reads", async () => { const createdAt = now(); const system = await createOrchestrationSystem(); @@ -812,6 +1019,7 @@ describe("OrchestrationEngine", () => { readAll() { return Stream.fromIterable(events); }, + hasEventAfter: () => Effect.succeed(false), }; const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { @@ -1048,6 +1256,7 @@ describe("OrchestrationEngine", () => { readAll() { return Stream.fromIterable(events); }, + hasEventAfter: () => Effect.succeed(false), }; let shouldFailProjection = true; diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts index 423a44a6ff15..f6a928fdc704 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts @@ -33,6 +33,7 @@ import { toPersistenceSqlError } from "../../persistence/Errors.ts"; import { OrchestrationEventStore } from "../../persistence/Services/OrchestrationEventStore.ts"; import { OrchestrationCommandReceiptRepository } from "../../persistence/Services/OrchestrationCommandReceipts.ts"; import { + isOrchestrationCommandRejection, OrchestrationCommandIdConflictError, OrchestrationCommandInvariantError, OrchestrationCommandPreviouslyRejectedError, @@ -43,6 +44,7 @@ import { decideOrchestrationCommand } from "../decider.ts"; import { createEmptyReadModel, projectEvent } from "../projector.ts"; import { OrchestrationProjectionPipeline } from "../Services/ProjectionPipeline.ts"; import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; +import { ThreadBackgroundLivenessService } from "../ThreadBackgroundLiveness.ts"; import { OrchestrationEngineService, type OrchestrationEngineShape, @@ -51,7 +53,6 @@ const isOrchestrationCommandPreviouslyRejectedError = Schema.is( OrchestrationCommandPreviouslyRejectedError, ); const isOrchestrationCommandIdConflictError = Schema.is(OrchestrationCommandIdConflictError); -const isOrchestrationCommandInvariantError = Schema.is(OrchestrationCommandInvariantError); interface CommandEnvelope { command: OrchestrationCommand; @@ -86,6 +87,7 @@ const makeOrchestrationEngine = Effect.gen(function* () { const commandReceiptRepository = yield* OrchestrationCommandReceiptRepository; const projectionPipeline = yield* OrchestrationProjectionPipeline; const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; + const threadBackgroundLiveness = yield* ThreadBackgroundLivenessService; const crypto = yield* Crypto.Crypto; const nowIso = Effect.map(DateTime.now, DateTime.formatIso); @@ -169,13 +171,37 @@ const makeOrchestrationEngine = Effect.gen(function* () { }); } + if ( + envelope.command.type === "thread.auto-settle" && + (yield* eventStore.hasEventAfter({ + aggregateKind: "thread", + aggregateId: envelope.command.threadId, + sequenceExclusive: envelope.command.snapshotSequence, + })) + ) { + return yield* new OrchestrationCommandInvariantError({ + commandType: envelope.command.type, + detail: `thread ${envelope.command.threadId} changed before automatic settlement`, + }); + } + + if ( + envelope.command.type === "thread.auto-settle" && + threadBackgroundLiveness.getThreadBackgroundLiveness(envelope.command.threadId) !== null + ) { + return yield* new OrchestrationCommandInvariantError({ + commandType: envelope.command.type, + detail: `thread ${envelope.command.threadId} has live background work`, + }); + } + const eventBase = yield* decideOrchestrationCommand({ command: envelope.command, readModel: commandReadModel, }).pipe( Effect.provideService(Crypto.Crypto, crypto), Effect.mapError((cause) => - isOrchestrationCommandInvariantError(cause) + isOrchestrationCommandRejection(cause) ? cause : new OrchestrationCommandInvariantError({ commandType: envelope.command.type, @@ -307,7 +333,7 @@ const makeOrchestrationEngine = Effect.gen(function* () { ), ); - if (isOrchestrationCommandInvariantError(error)) { + if (isOrchestrationCommandRejection(error)) { yield* commandReceiptRepository .upsert({ commandId: envelope.command.commandId, diff --git a/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts b/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts index 300d1526bb9a..1340480bce55 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts @@ -9,6 +9,7 @@ import { CheckpointReactor } from "../Services/CheckpointReactor.ts"; import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts"; import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts"; import { ThreadDeletionReactor } from "../Services/ThreadDeletionReactor.ts"; +import * as ThreadSettlementReactor from "../ThreadSettlementReactor.ts"; import { OrchestrationReactor } from "../Services/OrchestrationReactor.ts"; import { makeOrchestrationReactor } from "./OrchestrationReactor.ts"; import * as AgentAwarenessRelay from "../../relay/AgentAwarenessRelay.ts"; @@ -23,7 +24,7 @@ describe("OrchestrationReactor", () => { runtime = null; }); - it("starts provider ingestion, provider command, checkpoint, and thread deletion reactors", async () => { + it("starts every orchestration reactor", async () => { const started: string[] = []; runtime = ManagedRuntime.make( @@ -61,6 +62,15 @@ describe("OrchestrationReactor", () => { started.push("thread-deletion-reactor"); return Effect.void; }, + drainThrough: () => Effect.void, + }), + ), + Layer.provideMerge( + Layer.succeed(ThreadSettlementReactor.ThreadSettlementReactor, { + start: () => { + started.push("thread-settlement-reactor"); + return Effect.void; + }, drain: Effect.void, }), ), @@ -85,6 +95,7 @@ describe("OrchestrationReactor", () => { "provider-command-reactor", "checkpoint-reactor", "thread-deletion-reactor", + "thread-settlement-reactor", "agent-awareness-relay", ]); diff --git a/apps/server/src/orchestration/Layers/OrchestrationReactor.ts b/apps/server/src/orchestration/Layers/OrchestrationReactor.ts index fb7543e31af0..649e803809db 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationReactor.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationReactor.ts @@ -9,6 +9,7 @@ import { CheckpointReactor } from "../Services/CheckpointReactor.ts"; import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts"; import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts"; import { ThreadDeletionReactor } from "../Services/ThreadDeletionReactor.ts"; +import * as ThreadSettlementReactor from "../ThreadSettlementReactor.ts"; import * as AgentAwarenessRelay from "../../relay/AgentAwarenessRelay.ts"; export const makeOrchestrationReactor = Effect.gen(function* () { @@ -16,6 +17,7 @@ export const makeOrchestrationReactor = Effect.gen(function* () { const providerCommandReactor = yield* ProviderCommandReactor; const checkpointReactor = yield* CheckpointReactor; const threadDeletionReactor = yield* ThreadDeletionReactor; + const threadSettlementReactor = yield* ThreadSettlementReactor.ThreadSettlementReactor; const agentAwarenessRelay = yield* AgentAwarenessRelay.AgentAwarenessRelay; const start: OrchestrationReactorShape["start"] = Effect.fn("start")(function* () { @@ -23,6 +25,7 @@ export const makeOrchestrationReactor = Effect.gen(function* () { yield* providerCommandReactor.start(); yield* checkpointReactor.start(); yield* threadDeletionReactor.start(); + yield* threadSettlementReactor.start(); yield* agentAwarenessRelay.start(); }); diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index e3b18d74a9a7..32551643b0d4 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -9,6 +9,7 @@ import { TurnId, ProviderInstanceId, } from "@t3tools/contracts"; +import * as Option from "effect/Option"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; @@ -31,6 +32,7 @@ import { OrchestrationProjectionPipelineLive, } from "./ProjectionPipeline.ts"; import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; +import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts"; import * as ThreadPlanProgress from "../ThreadPlanProgress.ts"; import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; @@ -174,6 +176,78 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { assert.equal(row.lastAppliedSequence, 3); } + yield* sql`CREATE TABLE thread_shell_updates (count INTEGER NOT NULL)`; + yield* sql`INSERT INTO thread_shell_updates (count) VALUES (0)`; + yield* sql` + CREATE TRIGGER count_thread_shell_updates + AFTER UPDATE ON projection_threads + WHEN NEW.thread_id = 'thread-1' + BEGIN + UPDATE thread_shell_updates SET count = count + 1; + END; + `; + + yield* eventStore.append({ + type: "thread.message-sent", + eventId: EventId.make("evt-assistant-update"), + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-1"), + occurredAt: "2026-01-01T00:00:00.100Z", + commandId: CommandId.make("cmd-assistant-update"), + causationEventId: null, + correlationId: CommandId.make("cmd-assistant-update"), + metadata: {}, + payload: { + threadId: ThreadId.make("thread-1"), + messageId: MessageId.make("message-2"), + role: "assistant", + text: "more work", + turnId: null, + streaming: false, + createdAt: "2026-01-01T00:00:00.100Z", + updatedAt: "2026-01-01T00:00:00.100Z", + }, + }); + yield* projectionPipeline.bootstrap; + + let threadShellUpdates = yield* sql<{ readonly count: number }>` + SELECT count FROM thread_shell_updates + `; + assert.deepEqual(threadShellUpdates, [{ count: 1 }]); + + yield* sql`UPDATE thread_shell_updates SET count = 0`; + yield* eventStore.append({ + type: "thread.activity-appended", + eventId: EventId.make("evt-routine-activity"), + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-1"), + occurredAt: "2026-01-01T00:00:00.200Z", + commandId: CommandId.make("cmd-routine-activity"), + causationEventId: null, + correlationId: CommandId.make("cmd-routine-activity"), + metadata: {}, + payload: { + threadId: ThreadId.make("thread-1"), + activity: { + id: EventId.make("activity-routine"), + tone: "tool", + kind: "tool.updated", + summary: "Tool made progress", + payload: {}, + turnId: null, + createdAt: "2026-01-01T00:00:00.200Z", + }, + }, + }); + yield* projectionPipeline.bootstrap; + + threadShellUpdates = yield* sql<{ readonly count: number }>` + SELECT count FROM thread_shell_updates + `; + assert.deepEqual(threadShellUpdates, [{ count: 1 }]); + yield* sql`DROP TRIGGER count_thread_shell_updates`; + yield* sql`DROP TABLE thread_shell_updates`; + // Settled lifecycle through the DB pipeline: thread.settled writes the // override + timestamp, thread.unsettled(user) flips to the active pin. yield* eventStore.append({ @@ -197,15 +271,17 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { const settledRows = yield* sql<{ readonly settledOverride: string | null; readonly settledAt: string | null; + readonly unsettledAt: string | null; }>` SELECT settled_override AS "settledOverride", - settled_at AS "settledAt" + settled_at AS "settledAt", + unsettled_at AS "unsettledAt" FROM projection_threads WHERE thread_id = 'thread-1' `; assert.deepEqual(settledRows, [ - { settledOverride: "settled", settledAt: "2026-01-01T00:00:01.000Z" }, + { settledOverride: "settled", settledAt: "2026-01-01T00:00:01.000Z", unsettledAt: null }, ]); yield* eventStore.append({ @@ -229,14 +305,24 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { const unsettledRows = yield* sql<{ readonly settledOverride: string | null; readonly settledAt: string | null; + readonly unsettledAt: string | null; }>` SELECT settled_override AS "settledOverride", - settled_at AS "settledAt" + settled_at AS "settledAt", + unsettled_at AS "unsettledAt" FROM projection_threads WHERE thread_id = 'thread-1' `; - assert.deepEqual(unsettledRows, [{ settledOverride: "active", settledAt: null }]); + // The un-settle stamps the active-list re-entry time so clients can + // surface the thread at the top of the list. + assert.deepEqual(unsettledRows, [ + { + settledOverride: "active", + settledAt: null, + unsettledAt: "2026-01-01T00:00:02.000Z", + }, + ]); }), ); }); @@ -798,6 +884,7 @@ it.layer( const now = "2026-01-01T00:00:00.000Z"; const threadId = ThreadId.make("Thread Revert.Files"); const keepAttachmentId = "thread-revert-files-00000000-0000-4000-8000-000000000001"; + const keepFileAttachmentId = "thread-revert-files-00000000-0000-4000-8000-000000000004-pdf"; const removeAttachmentId = "thread-revert-files-00000000-0000-4000-8000-000000000002"; const otherThreadAttachmentId = "thread-revert-files-extra-00000000-0000-4000-8000-000000000003"; @@ -899,6 +986,13 @@ it.layer( mimeType: "image/png", sizeBytes: 5, }, + { + type: "file", + id: keepFileAttachmentId, + name: "keep.pdf", + mimeType: "application/pdf", + sizeBytes: 5, + }, ], turnId: TurnId.make("turn-keep"), streaming: false, @@ -961,9 +1055,11 @@ it.layer( }); const keepPath = path.join(attachmentsDir, `${keepAttachmentId}.png`); + const keepFilePath = path.join(attachmentsDir, `${keepFileAttachmentId}.pdf`); const removePath = path.join(attachmentsDir, `${removeAttachmentId}.png`); yield* fileSystem.makeDirectory(attachmentsDir, { recursive: true }); yield* fileSystem.writeFileString(keepPath, "keep"); + yield* fileSystem.writeFileString(keepFilePath, "keep"); yield* fileSystem.writeFileString(removePath, "remove"); const otherThreadPath = path.join(attachmentsDir, `${otherThreadAttachmentId}.png`); yield* fileSystem.writeFileString(otherThreadPath, "other"); @@ -988,6 +1084,7 @@ it.layer( }); assert.isTrue(yield* exists(keepPath)); + assert.isTrue(yield* exists(keepFilePath)); assert.isFalse(yield* exists(removePath)); assert.isTrue(yield* exists(otherThreadPath)); }), @@ -1007,6 +1104,7 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-atta const now = "2026-01-01T00:00:00.000Z"; const threadId = ThreadId.make("Thread Delete.Files"); const attachmentId = "thread-delete-files-00000000-0000-4000-8000-000000000001"; + const fileAttachmentId = "thread-delete-files-00000000-0000-4000-8000-000000000003-pdf"; const otherThreadAttachmentId = "thread-delete-files-extra-00000000-0000-4000-8000-000000000002"; @@ -1085,6 +1183,13 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-atta mimeType: "image/png", sizeBytes: 5, }, + { + type: "file", + id: fileAttachmentId, + name: "delete.pdf", + mimeType: "application/pdf", + sizeBytes: 6, + }, ], turnId: null, streaming: false, @@ -1094,14 +1199,17 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-atta }); const threadAttachmentPath = path.join(attachmentsDir, `${attachmentId}.png`); + const threadFileAttachmentPath = path.join(attachmentsDir, `${fileAttachmentId}.pdf`); const otherThreadAttachmentPath = path.join( attachmentsDir, `${otherThreadAttachmentId}.png`, ); yield* fileSystem.makeDirectory(attachmentsDir, { recursive: true }); yield* fileSystem.writeFileString(threadAttachmentPath, "delete"); + yield* fileSystem.writeFileString(threadFileAttachmentPath, "delete"); yield* fileSystem.writeFileString(otherThreadAttachmentPath, "other-thread"); assert.isTrue(yield* exists(threadAttachmentPath)); + assert.isTrue(yield* exists(threadFileAttachmentPath)); assert.isTrue(yield* exists(otherThreadAttachmentPath)); yield* appendAndProject({ @@ -1121,6 +1229,7 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-atta }); assert.isFalse(yield* exists(threadAttachmentPath)); + assert.isFalse(yield* exists(threadFileAttachmentPath)); assert.isTrue(yield* exists(otherThreadAttachmentPath)); }), ); @@ -1170,13 +1279,191 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-atta }, ); +it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-attachments-replay-")))( + "OrchestrationProjectionPipeline", + (it) => { + it.effect("replaying a superseded thread.deleted keeps the re-created thread's files", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const projectionPipeline = yield* OrchestrationProjectionPipeline; + const eventStore = yield* OrchestrationEventStore; + const { attachmentsDir } = yield* ServerConfig; + const now = "2026-01-01T00:00:00.000Z"; + const projectId = ProjectId.make("project-replay"); + const retriedThreadId = ThreadId.make("thread-replay-retried"); + const goneThreadId = ThreadId.make("thread-replay-gone"); + const retriedAttachmentPath = path.join( + attachmentsDir, + "thread-replay-retried-00000000-0000-4000-8000-000000000001.png", + ); + const goneAttachmentPath = path.join( + attachmentsDir, + "thread-replay-gone-00000000-0000-4000-8000-000000000002.png", + ); + const threadCreated = (threadId: ThreadId, suffix: string) => + eventStore.append({ + type: "thread.created", + eventId: EventId.make(`evt-replay-create-${suffix}`), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: now, + commandId: CommandId.make(`cmd-replay-create-${suffix}`), + causationEventId: null, + correlationId: CorrelationId.make(`cmd-replay-create-${suffix}`), + metadata: {}, + payload: { + threadId, + projectId, + title: `Thread ${suffix}`, + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt: now, + updatedAt: now, + }, + }); + const threadDeleted = (threadId: ThreadId, suffix: string) => + eventStore.append({ + type: "thread.deleted", + eventId: EventId.make(`evt-replay-delete-${suffix}`), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: now, + commandId: CommandId.make(`cmd-replay-delete-${suffix}`), + causationEventId: null, + correlationId: CorrelationId.make(`cmd-replay-delete-${suffix}`), + metadata: {}, + payload: { threadId, deletedAt: now }, + }); + + yield* eventStore.append({ + type: "project.created", + eventId: EventId.make("evt-replay-project"), + aggregateKind: "project", + aggregateId: projectId, + occurredAt: now, + commandId: CommandId.make("cmd-replay-project"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-replay-project"), + metadata: {}, + payload: { + projectId, + title: "Replay", + workspaceRoot: "/tmp/project-replay", + defaultModelSelection: null, + scripts: [], + createdAt: now, + updatedAt: now, + }, + }); + // A failed first send: create, roll back, then the draft retries the id. + yield* threadCreated(retriedThreadId, "retried-1"); + yield* threadDeleted(retriedThreadId, "retried"); + yield* threadCreated(retriedThreadId, "retried-2"); + // A thread that was deleted for good. + yield* threadCreated(goneThreadId, "gone"); + yield* threadDeleted(goneThreadId, "gone"); + + // Files on disk are not event-sourced: by the time anything replays, + // the retried thread's attachments already belong to its second life. + yield* fileSystem.makeDirectory(attachmentsDir, { recursive: true }); + yield* fileSystem.writeFileString(retriedAttachmentPath, "second incarnation"); + yield* fileSystem.writeFileString(goneAttachmentPath, "gone"); + + yield* projectionPipeline.bootstrap; + + assert.isTrue(yield* exists(retriedAttachmentPath)); + assert.isFalse(yield* exists(goneAttachmentPath)); + }), + ); + }, +); + it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { + it.effect("replays a bootstrap backlog larger than the event store default limit", () => + Effect.gen(function* () { + const projectionPipeline = yield* OrchestrationProjectionPipeline; + const eventStore = yield* OrchestrationEventStore; + const sql = yield* SqlClient.SqlClient; + const now = "2026-01-01T00:00:00.000Z"; + const projectId = ProjectId.make("project-bootstrap-backlog"); + + const sequenceRows = yield* sql<{ readonly maxSequence: number | null }>` + SELECT MAX(sequence) AS "maxSequence" FROM orchestration_events + `; + const sequenceBeforeBacklog = sequenceRows[0]?.maxSequence ?? 0; + const appendedEvents = yield* Effect.forEach( + Array.from({ length: 1_001 }, (_, index) => index), + (index) => { + const eventId = EventId.make(`evt-bootstrap-backlog-${index}`); + const commandId = CommandId.make(`cmd-bootstrap-backlog-${index}`); + return eventStore.append({ + type: "project.created", + eventId, + aggregateKind: "project", + aggregateId: projectId, + occurredAt: now, + commandId, + causationEventId: null, + correlationId: CorrelationId.make(commandId), + metadata: {}, + payload: { + projectId, + title: `Bootstrap backlog ${index}`, + workspaceRoot: "/tmp/project-bootstrap-backlog", + defaultModelSelection: null, + scripts: [], + createdAt: now, + updatedAt: now, + }, + }); + }, + ); + const lastSequence = appendedEvents[appendedEvents.length - 1]!.sequence; + + yield* Effect.forEach( + Object.values(ORCHESTRATION_PROJECTOR_NAMES), + (projector) => { + const lastAppliedSequence = + projector === ORCHESTRATION_PROJECTOR_NAMES.projects + ? sequenceBeforeBacklog + : lastSequence; + return sql` + INSERT INTO projection_state (projector, last_applied_sequence, updated_at) + VALUES (${projector}, ${lastAppliedSequence}, ${now}) + ON CONFLICT (projector) + DO UPDATE SET + last_applied_sequence = excluded.last_applied_sequence, + updated_at = excluded.updated_at + `; + }, + { discard: true }, + ); + + yield* projectionPipeline.bootstrap; + + const stateRows = yield* sql<{ readonly lastAppliedSequence: number }>` + SELECT last_applied_sequence AS "lastAppliedSequence" + FROM projection_state + WHERE projector = ${ORCHESTRATION_PROJECTOR_NAMES.projects} + `; + assert.deepEqual(stateRows, [{ lastAppliedSequence: lastSequence }]); + }), + ); + it.effect("resumes from projector last_applied_sequence without replaying older events", () => Effect.gen(function* () { const projectionPipeline = yield* OrchestrationProjectionPipeline; const eventStore = yield* OrchestrationEventStore; const sql = yield* SqlClient.SqlClient; const now = "2026-01-01T00:00:00.000Z"; + const streamingAt = "2026-01-01T00:00:01.000Z"; + const completedAt = "2026-01-01T00:00:02.000Z"; yield* eventStore.append({ type: "project.created", @@ -1241,7 +1528,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { role: "assistant", text: "hello", turnId: null, - streaming: false, + streaming: true, createdAt: now, updatedAt: now, }, @@ -1254,7 +1541,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { eventId: EventId.make("evt-a4"), aggregateKind: "thread", aggregateId: ThreadId.make("thread-a"), - occurredAt: now, + occurredAt: streamingAt, commandId: CommandId.make("cmd-a4"), causationEventId: null, correlationId: CorrelationId.make("cmd-a4"), @@ -1266,18 +1553,61 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { text: " world", turnId: null, streaming: true, - createdAt: now, - updatedAt: now, + createdAt: streamingAt, + updatedAt: streamingAt, + }, + }); + + yield* projectionPipeline.bootstrap; + yield* projectionPipeline.bootstrap; + + yield* eventStore.append({ + type: "thread.message-sent", + eventId: EventId.make("evt-a5"), + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-a"), + occurredAt: completedAt, + commandId: CommandId.make("cmd-a5"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-a5"), + metadata: {}, + payload: { + threadId: ThreadId.make("thread-a"), + messageId: MessageId.make("message-a"), + role: "assistant", + text: "", + turnId: null, + streaming: false, + createdAt: completedAt, + updatedAt: completedAt, }, }); yield* projectionPipeline.bootstrap; yield* projectionPipeline.bootstrap; - const messageRows = yield* sql<{ readonly text: string }>` - SELECT text FROM projection_thread_messages WHERE message_id = 'message-a' + const messageRows = yield* sql<{ + readonly text: string; + readonly isStreaming: number; + readonly createdAt: string; + readonly updatedAt: string; + }>` + SELECT + text, + is_streaming AS "isStreaming", + created_at AS "createdAt", + updated_at AS "updatedAt" + FROM projection_thread_messages + WHERE message_id = 'message-a' `; - assert.deepEqual(messageRows, [{ text: "hello world" }]); + assert.deepEqual(messageRows, [ + { + text: "hello world", + isStreaming: 0, + createdAt: now, + updatedAt: completedAt, + }, + ]); const stateRows = yield* sql<{ readonly projector: string; @@ -1950,7 +2280,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { }), ); - it.effect("clears stale pending user input from projected shell summaries", () => + it.effect("reads only user-input activities when refreshing shell summaries", () => Effect.gen(function* () { const projectionPipeline = yield* OrchestrationProjectionPipeline; const eventStore = yield* OrchestrationEventStore; @@ -2008,70 +2338,128 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { }, }); + // Invalid JSON proves the summary query filters tool rows before decoding payloads. + yield* sql` + INSERT INTO projection_thread_activities ( + activity_id, + thread_id, + turn_id, + tone, + kind, + summary, + payload_json, + sequence, + created_at + ) + VALUES + ( + 'activity-malformed-tool-output', + 'thread-stale-user-input', + NULL, + 'info', + 'tool.completed', + 'Tool completed', + '{not-json', + NULL, + '2026-02-26T12:35:02.000Z' + ), + ( + 'activity-user-input-resolved-requested', + 'thread-stale-user-input', + NULL, + 'info', + 'user-input.requested', + 'User input requested', + json_object('requestId', 'user-input-resolved'), + NULL, + '2026-02-26T12:35:03.000Z' + ), + ( + 'activity-user-input-resolved', + 'thread-stale-user-input', + NULL, + 'info', + 'user-input.resolved', + 'User input resolved', + json_object('requestId', 'user-input-resolved'), + NULL, + '2026-02-26T12:35:04.000Z' + ), + ( + 'activity-user-input-stale-requested', + 'thread-stale-user-input', + NULL, + 'info', + 'user-input.requested', + 'User input requested', + json_object('requestId', 'user-input-stale'), + NULL, + '2026-02-26T12:35:05.000Z' + ), + ( + 'activity-user-input-stale-failed', + 'thread-stale-user-input', + NULL, + 'error', + 'provider.user-input.respond.failed', + 'Provider user input response failed', + json_object( + 'requestId', + 'user-input-stale', + 'detail', + 'Unknown pending Codex user input request: user-input-stale' + ), + NULL, + '2026-02-26T12:35:06.000Z' + ), + ( + 'activity-user-input-active-requested', + 'thread-stale-user-input', + NULL, + 'info', + 'user-input.requested', + 'User input requested', + json_object('requestId', 'user-input-active'), + NULL, + '2026-02-26T12:35:07.000Z' + ), + ( + 'activity-user-input-active-failed', + 'thread-stale-user-input', + NULL, + 'error', + 'provider.user-input.respond.failed', + 'Provider user input response failed', + json_object( + 'requestId', + 'user-input-active', + 'detail', + 'Provider is temporarily unavailable' + ), + NULL, + '2026-02-26T12:35:08.000Z' + ) + `; + yield* appendAndProject({ - type: "thread.activity-appended", + type: "thread.message-sent", eventId: EventId.make("evt-stale-user-input-3"), aggregateKind: "thread", aggregateId: ThreadId.make("thread-stale-user-input"), - occurredAt: "2026-02-26T12:35:02.000Z", + occurredAt: "2026-02-26T12:35:09.000Z", commandId: CommandId.make("cmd-stale-user-input-3"), causationEventId: null, correlationId: CorrelationId.make("cmd-stale-user-input-3"), metadata: {}, payload: { threadId: ThreadId.make("thread-stale-user-input"), - activity: { - id: EventId.make("activity-stale-user-input-requested"), - tone: "info", - kind: "user-input.requested", - summary: "User input requested", - payload: { - requestId: "user-input-request-stale-1", - questions: [ - { - id: "sandbox_mode", - header: "Sandbox", - question: "Which mode should be used?", - options: [ - { - label: "workspace-write", - description: "Allow workspace writes only", - }, - ], - }, - ], - }, - turnId: null, - createdAt: "2026-02-26T12:35:02.000Z", - }, - }, - }); - - yield* appendAndProject({ - type: "thread.activity-appended", - eventId: EventId.make("evt-stale-user-input-4"), - aggregateKind: "thread", - aggregateId: ThreadId.make("thread-stale-user-input"), - occurredAt: "2026-02-26T12:35:03.000Z", - commandId: CommandId.make("cmd-stale-user-input-4"), - causationEventId: null, - correlationId: CorrelationId.make("cmd-stale-user-input-4"), - metadata: {}, - payload: { - threadId: ThreadId.make("thread-stale-user-input"), - activity: { - id: EventId.make("activity-stale-user-input-failed"), - tone: "error", - kind: "provider.user-input.respond.failed", - summary: "Provider user input response failed", - payload: { - requestId: "user-input-request-stale-1", - detail: - "Provider adapter request failed (codex) for item/tool/requestUserInput: Unknown pending Codex user input request: user-input-request-stale-1", - }, - turnId: null, - createdAt: "2026-02-26T12:35:03.000Z", - }, + messageId: MessageId.make("message-stale-user-input"), + role: "user", + text: "Continue", + turnId: null, + streaming: false, + createdAt: "2026-02-26T12:35:09.000Z", + updatedAt: "2026-02-26T12:35:09.000Z", }, }); @@ -2082,7 +2470,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { FROM projection_threads WHERE thread_id = 'thread-stale-user-input' `; - assert.deepEqual(threadRows, [{ pendingUserInputCount: 0 }]); + assert.deepEqual(threadRows, [{ pendingUserInputCount: 1 }]); }), ); @@ -2672,7 +3060,7 @@ it.effect("restores pending turn-start metadata across projection pipeline resta const engineLayer = it.layer( OrchestrationEngineLive.pipe( - Layer.provide(OrchestrationProjectionSnapshotQueryLive), + Layer.provideMerge(OrchestrationProjectionSnapshotQueryLive), Layer.provide(ThreadBackgroundLiveness.layer), Layer.provide(ThreadPlanProgress.layer), Layer.provide(OrchestrationProjectionPipelineLive), @@ -2789,4 +3177,147 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => { ]); }), ); + + it.effect("re-creating a deleted thread id starts from an empty projection", () => + Effect.gen(function* () { + const engine = yield* OrchestrationEngineService; + const snapshotQuery = yield* ProjectionSnapshotQuery; + const sql = yield* SqlClient.SqlClient; + const createdAt = "2026-01-01T00:00:00.000Z"; + const projectId = ProjectId.make("project-retry"); + const threadId = ThreadId.make("thread-retry"); + const modelSelection = { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }; + const createThread = (commandId: string, title: string) => + engine.dispatch({ + type: "thread.create", + commandId: CommandId.make(commandId), + threadId, + projectId, + title, + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt, + }); + const countRowsForThread = (table: string) => + sql<{ readonly count: number }>` + SELECT COUNT(*) AS count FROM ${sql(table)} WHERE thread_id = ${threadId} + `.pipe(Effect.map((rows) => rows[0]?.count ?? 0)); + const perThreadTables = [ + "projection_thread_messages", + "projection_thread_activities", + "projection_thread_sessions", + "projection_turns", + "projection_thread_proposed_plans", + "projection_pending_approvals", + ]; + + yield* engine.dispatch({ + type: "project.create", + commandId: CommandId.make("cmd-retry-project"), + projectId, + title: "Retry Project", + workspaceRoot: "/tmp/project-retry", + defaultModelSelection: modelSelection, + createdAt, + }); + + // First attempt: the thread gets a turn, a message, an activity, and a + // running session before its bootstrap fails and the server rolls back. + yield* createThread("cmd-retry-create-1", "First attempt"); + yield* engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-retry-turn-1"), + threadId, + message: { + messageId: MessageId.make("message-retry-1"), + role: "user", + text: "first attempt", + attachments: [], + }, + runtimeMode: "full-access", + interactionMode: "default", + createdAt, + }); + yield* engine.dispatch({ + type: "thread.activity.append", + commandId: CommandId.make("cmd-retry-activity-1"), + threadId, + activity: { + id: EventId.make("activity-retry-1"), + tone: "info", + kind: "approval.requested", + summary: "approval requested", + payload: { requestId: "request-retry-1" }, + turnId: null, + createdAt, + }, + createdAt, + }); + yield* engine.dispatch({ + type: "thread.proposed-plan.upsert", + commandId: CommandId.make("cmd-retry-plan-1"), + threadId, + proposedPlan: { + id: "plan-retry-1", + turnId: null, + planMarkdown: "# Plan", + implementedAt: null, + implementationThreadId: null, + createdAt, + updatedAt: createdAt, + }, + createdAt, + }); + yield* engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-retry-session-1"), + threadId, + session: { + threadId, + status: "running", + providerName: "codex", + runtimeMode: "full-access", + activeTurnId: TurnId.make("turn-retry-1"), + lastError: null, + updatedAt: createdAt, + }, + createdAt, + }); + for (const table of perThreadTables) { + assert.isAbove(yield* countRowsForThread(table), 0, `${table} should be populated`); + } + const populatedShell = Option.getOrThrow(yield* snapshotQuery.getThreadShellById(threadId)); + assert.isTrue(populatedShell.hasPendingApprovals); + assert.isTrue(populatedShell.hasActionableProposedPlan); + + yield* engine.dispatch({ + type: "thread.delete", + commandId: CommandId.make("cmd-retry-delete"), + threadId, + }); + assert.isTrue(Option.isNone(yield* snapshotQuery.getThreadShellById(threadId))); + + // Retry from the same draft reuses the thread id. + yield* createThread("cmd-retry-create-2", "Second attempt"); + + const shell = Option.getOrThrow(yield* snapshotQuery.getThreadShellById(threadId)); + assert.strictEqual(shell.title, "Second attempt"); + assert.isFalse(shell.hasPendingApprovals); + assert.isFalse(shell.hasActionableProposedPlan); + for (const table of perThreadTables) { + assert.strictEqual(yield* countRowsForThread(table), 0, `${table} should be empty`); + } + const detail = Option.getOrThrow(yield* snapshotQuery.getThreadDetailById(threadId)); + assert.deepEqual(detail.messages, []); + assert.deepEqual(detail.activities, []); + assert.isNull(detail.latestTurn); + assert.isNull(detail.session); + }), + ); }); diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index d06bb03ec749..22daeee69365 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -130,6 +130,29 @@ function isStalePendingApprovalFailureDetail(detail: string | null): boolean { ); } +// A refresh reads each persisted summary source, so skip events that cannot change the result. +function shouldRefreshThreadShellSummary(event: OrchestrationEvent): boolean { + if (event.type === "thread.message-sent") { + return event.payload.role === "user"; + } + + if (event.type !== "thread.activity-appended") { + return true; + } + + switch (event.payload.activity.kind) { + case "approval.requested": + case "approval.resolved": + case "provider.approval.respond.failed": + case "user-input.requested": + case "user-input.resolved": + case "provider.user-input.respond.failed": + return true; + default: + return false; + } +} + function derivePendingUserInputCountFromActivities( activities: ReadonlyArray, ): number { @@ -338,14 +361,14 @@ function collectThreadAttachmentRelativePaths( const relativePaths = new Set(); for (const message of messages) { for (const attachment of message.attachments ?? []) { - if (attachment.type !== "image") { - continue; - } const attachmentThreadSegment = parseThreadSegmentFromAttachmentId(attachment.id); if (!attachmentThreadSegment || attachmentThreadSegment !== threadSegment) { continue; } - relativePaths.add(attachmentRelativePath(attachment)); + const relativePath = attachmentRelativePath(attachment); + if (relativePath) { + relativePaths.add(relativePath); + } } } return relativePaths; @@ -565,7 +588,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti const [messages, proposedPlans, activities, pendingApprovals] = yield* Effect.all([ projectionThreadMessageRepository.listByThreadId({ threadId }), projectionThreadProposedPlanRepository.listByThreadId({ threadId }), - projectionThreadActivityRepository.listByThreadId({ threadId }), + projectionThreadActivityRepository.listUserInputLifecycleByThreadId({ threadId }), projectionPendingApprovalRepository.listByThreadId({ threadId }), ]); @@ -611,12 +634,14 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti interactionMode: event.payload.interactionMode, branch: event.payload.branch, worktreePath: event.payload.worktreePath, + linkedPullRequest: null, latestTurnId: null, createdAt: event.payload.createdAt, updatedAt: event.payload.updatedAt, archivedAt: null, settledOverride: null, settledAt: null, + unsettledAt: null, snoozedUntil: null, snoozedAt: null, pinnedAt: null, @@ -674,6 +699,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti ...existingRow.value, settledOverride: "settled", settledAt: event.payload.settledAt, + unsettledAt: null, updatedAt: event.payload.updatedAt, }); return; @@ -690,6 +716,13 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti ...existingRow.value, settledOverride: event.payload.reason === "user" ? "active" : null, settledAt: null, + // Re-entry stamp for active-list ordering. A thread already pinned + // active keeps its stamp: the activity reset that clears the pin + // is not a re-entry and must not reorder the list. + unsettledAt: + existingRow.value.settledOverride === "active" + ? existingRow.value.unsettledAt + : event.payload.updatedAt, updatedAt: event.payload.updatedAt, }); return; @@ -799,6 +832,9 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti ...(event.payload.worktreePath !== undefined ? { worktreePath: event.payload.worktreePath } : {}), + ...(event.payload.linkedPullRequest !== undefined + ? { linkedPullRequest: event.payload.linkedPullRequest } + : {}), updatedAt: event.payload.updatedAt, }); return; @@ -835,7 +871,18 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti } case "thread.deleted": { - attachmentSideEffects.deletedThreadIds.add(event.payload.threadId); + // A draft retry can re-create this id later in the log. During + // replay the attachment files on disk already belong to that later + // incarnation, so only an unsuperseded deletion removes them. + const recreatedLater = yield* eventStore.hasEventAfter({ + aggregateKind: "thread", + aggregateId: event.payload.threadId, + type: "thread.created", + sequenceExclusive: event.sequence, + }); + if (!recreatedLater) { + attachmentSideEffects.deletedThreadIds.add(event.payload.threadId); + } const existingRow = yield* projectionThreadRepository.getById({ threadId: event.payload.threadId, }); @@ -865,7 +912,9 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti ...existingRow.value, updatedAt: event.occurredAt, }); - yield* refreshThreadShellSummary(event.payload.threadId); + if (shouldRefreshThreadShellSummary(event)) { + yield* refreshThreadShellSummary(event.payload.threadId); + } return; } @@ -949,22 +998,44 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti "applyThreadMessagesProjection", )(function* (event, attachmentSideEffects) { switch (event.type) { + // A draft retry re-creates a soft-deleted thread id. Every projector + // drops its own rows for the old incarnation here so replay from any + // per-projector cursor rebuilds the new thread without stale history. + case "thread.created": + yield* projectionThreadMessageRepository.deleteByThreadId({ + threadId: event.payload.threadId, + }); + return; + case "thread.message-sent": { + if (event.payload.streaming) { + const attachments = + event.payload.attachments !== undefined + ? yield* materializeAttachmentsForProjection({ + attachments: event.payload.attachments, + }) + : undefined; + yield* projectionThreadMessageRepository.appendStreaming({ + messageId: event.payload.messageId, + threadId: event.payload.threadId, + turnId: event.payload.turnId, + role: event.payload.role, + text: event.payload.text, + ...(attachments !== undefined ? { attachments: [...attachments] } : {}), + createdAt: event.payload.createdAt, + updatedAt: event.payload.updatedAt, + }); + return; + } + const existingMessage = yield* projectionThreadMessageRepository.getByMessageId({ messageId: event.payload.messageId, }); const previousMessage = Option.getOrUndefined(existingMessage); const nextText = Option.match(existingMessage, { onNone: () => event.payload.text, - onSome: (message) => { - if (event.payload.streaming) { - return `${message.text}${event.payload.text}`; - } - if (event.payload.text.length === 0) { - return message.text; - } - return event.payload.text; - }, + onSome: (message) => + event.payload.text.length === 0 ? message.text : event.payload.text, }); const nextAttachments = event.payload.attachments !== undefined @@ -979,7 +1050,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti role: event.payload.role, text: nextText, ...(nextAttachments !== undefined ? { attachments: [...nextAttachments] } : {}), - isStreaming: event.payload.streaming, + isStreaming: false, createdAt: previousMessage?.createdAt ?? event.payload.createdAt, updatedAt: event.payload.updatedAt, }); @@ -1028,6 +1099,12 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti "applyThreadProposedPlansProjection", )(function* (event, _attachmentSideEffects) { switch (event.type) { + case "thread.created": + yield* projectionThreadProposedPlanRepository.deleteByThreadId({ + threadId: event.payload.threadId, + }); + return; + case "thread.proposed-plan-upserted": yield* projectionThreadProposedPlanRepository.upsert({ planId: event.payload.proposedPlan.id, @@ -1079,6 +1156,12 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti "applyThreadActivitiesProjection", )(function* (event, _attachmentSideEffects) { switch (event.type) { + case "thread.created": + yield* projectionThreadActivityRepository.deleteByThreadId({ + threadId: event.payload.threadId, + }); + return; + case "thread.activity-appended": yield* projectionThreadActivityRepository.upsert({ activityId: event.payload.activity.id, @@ -1130,6 +1213,12 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti const applyThreadSessionsProjection: ProjectorDefinition["apply"] = Effect.fn( "applyThreadSessionsProjection", )(function* (event, _attachmentSideEffects) { + if (event.type === "thread.created") { + yield* projectionThreadSessionRepository.deleteByThreadId({ + threadId: event.payload.threadId, + }); + return; + } if (event.type !== "thread.session-set") { return; } @@ -1149,6 +1238,12 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti "applyThreadTurnsProjection", )(function* (event, _attachmentSideEffects) { switch (event.type) { + case "thread.created": + yield* projectionTurnRepository.deleteByThreadId({ + threadId: event.payload.threadId, + }); + return; + case "thread.turn-start-requested": { yield* projectionTurnRepository.replacePendingTurnStart({ threadId: event.payload.threadId, @@ -1486,6 +1581,12 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti "applyPendingApprovalsProjection", )(function* (event, _attachmentSideEffects) { switch (event.type) { + case "thread.created": + yield* projectionPendingApprovalRepository.deleteByThreadId({ + threadId: event.payload.threadId, + }); + return; + case "thread.activity-appended": { const requestId = extractActivityRequestId(event.payload.activity.payload) ?? @@ -1689,6 +1790,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti Stream.runForEach( eventStore.readFromSequence( Option.isSome(stateRow) ? stateRow.value.lastAppliedSequence : 0, + Number.MAX_SAFE_INTEGER, ), (event) => runProjectorForEvent(projector, event), ), diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index 83ae3cfe049a..fd8b601b13ea 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -21,6 +21,7 @@ import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts"; import * as ThreadPlanProgress from "../ThreadPlanProgress.ts"; import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; import { encodeThreadDetailPageCursor } from "../threadDetailCursor.ts"; +import { projectThreadDetailSnapshot } from "../ActivityPayloadProjection.ts"; const asProjectId = (value: string): ProjectId => ProjectId.make(value); const asTurnId = (value: string): TurnId => TurnId.make(value); @@ -82,6 +83,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { interaction_mode, branch, worktree_path, + linked_pull_request_json, latest_turn_id, latest_user_message_at, pending_approval_count, @@ -102,6 +104,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { 'default', NULL, NULL, + '{"projectId":"project-1","repository":"pingdotgg/t3code","number":42,"url":"https://github.com/pingdotgg/t3code/pull/42"}', 'turn-1', '2026-02-24T00:00:04.000Z', 1, @@ -304,6 +307,12 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { runtimeMode: "full-access", branch: null, worktreePath: null, + linkedPullRequest: { + projectId: asProjectId("project-1"), + repository: "pingdotgg/t3code", + number: 42, + url: "https://github.com/pingdotgg/t3code/pull/42", + }, latestTurn: { turnId: asTurnId("turn-1"), state: "completed", @@ -321,6 +330,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { archivedAt: null, settledOverride: null, settledAt: null, + unsettledAt: null, snoozedUntil: null, snoozedAt: null, pinnedAt: "2026-02-24T00:00:01.000Z", @@ -423,6 +433,12 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { runtimeMode: "full-access", branch: null, worktreePath: null, + linkedPullRequest: { + projectId: asProjectId("project-1"), + repository: "pingdotgg/t3code", + number: 42, + url: "https://github.com/pingdotgg/t3code/pull/42", + }, latestTurn: { turnId: asTurnId("turn-1"), state: "completed", @@ -440,6 +456,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { archivedAt: null, settledOverride: null, settledAt: null, + unsettledAt: null, snoozedUntil: null, snoozedAt: null, pinnedAt: "2026-02-24T00:00:01.000Z", @@ -468,6 +485,77 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { if (threadDetail._tag === "Some") { assert.deepEqual(threadDetail.value, snapshot.threads[0]); } + + yield* sql` + INSERT INTO projection_thread_activities ( + activity_id, + thread_id, + turn_id, + tone, + kind, + summary, + payload_json, + created_at + ) + VALUES + ( + 'activity-task-started', + 'thread-1', + 'turn-1', + 'info', + 'task.started', + 'Ship the query filter', + '{"taskId":"task-1","detail":"Ship the query filter"}', + '2026-02-24T00:00:06.100Z' + ), + ( + 'activity-malformed-tool', + 'thread-1', + 'turn-1', + 'info', + 'tool.completed', + 'Malformed tool output', + 'not-json', + '2026-02-24T00:00:06.200Z' + ) + `; + + const detailWithoutActivities = yield* snapshotQuery.getThreadDetailById( + ThreadId.make("thread-1"), + { activityKinds: [] }, + ); + assert.equal(detailWithoutActivities._tag, "Some"); + if (detailWithoutActivities._tag === "Some") { + assert.deepEqual(detailWithoutActivities.value.activities, []); + assert.deepEqual(detailWithoutActivities.value.messages, snapshot.threads[0]?.messages); + assert.deepEqual( + detailWithoutActivities.value.proposedPlans, + snapshot.threads[0]?.proposedPlans, + ); + assert.deepEqual( + detailWithoutActivities.value.checkpoints, + snapshot.threads[0]?.checkpoints, + ); + } + + const detailWithTaskActivities = yield* snapshotQuery.getThreadDetailById( + ThreadId.make("thread-1"), + { activityKinds: ["task.started", "task.progress"] }, + ); + assert.equal(detailWithTaskActivities._tag, "Some"); + if (detailWithTaskActivities._tag === "Some") { + assert.deepEqual(detailWithTaskActivities.value.activities, [ + { + id: asEventId("activity-task-started"), + tone: "info", + kind: "task.started", + summary: "Ship the query filter", + payload: { taskId: "task-1", detail: "Ship the query filter" }, + turnId: asTurnId("turn-1"), + createdAt: "2026-02-24T00:00:06.100Z", + }, + ]); + } }), ); @@ -2302,9 +2390,84 @@ projectionSnapshotLayer("ProjectionSnapshotQuery windowed thread detail", (it) = 'thread-w', 'turn-5', 'tool', - 'tool.completed', + CASE + WHEN sequence = 2 THEN 'tool.updated' + WHEN sequence IN (3, 70) THEN 'context-window.updated' + ELSE 'tool.completed' + END, 'ran tool', - printf('{"sequence":%d}', sequence), + CASE + WHEN sequence IN (2, 80) THEN json_object( + 'itemType', 'command_execution', + 'toolCallId', 'cross-batch-call', + 'title', CASE WHEN sequence = 80 THEN 'Build completed' ELSE 'Build' END, + 'status', 'completed', + 'data', json_object( + 'toolCallId', 'cross-batch-call', + 'item', json_object( + 'command', 'vp test run', + 'aggregatedOutput', printf( + 'command output%s%s', + char(10), + replace(hex(zeroblob(8192)), '00', 'x') + ) + ), + 'rawOutput', printf( + 'raw output%s%s', + char(10), + replace(hex(zeroblob(8192)), '00', 'y') + ), + 'files', json_array(json_object('path', 'apps/server/src/snapshot.ts')) + ) + ) + WHEN sequence = 10 THEN json_object( + 'itemType', 'mcp_tool_call', + 'status', 'completed', + 'data', json_object( + 'item', json_object( + 'type', 'mcpToolCall', + 'id', 'mcp-item-10', + 'tool', 'fetch_pr', + 'server', 'github', + 'status', 'completed', + 'arguments', json_object('pr', 42), + 'result', json_object( + 'content', json_array(json_object( + 'type', 'text', + 'text', printf( + 'PR body line one%s%s', + char(10), + replace(hex(zeroblob(8192)), '00', 'z') + ) + )) + ), + '_meta', json_object('raw', replace(hex(zeroblob(8192)), '00', 'q')) + ) + ) + ) + WHEN sequence = 11 THEN json_object( + 'itemType', 'command_execution', + 'status', 'completed', + 'data', json_object( + 'item', json_object( + 'status', 'failed', + 'command', 'vp test run', + 'aggregatedOutput', printf( + 'failed command%s%s', + char(10), + replace(hex(zeroblob(8192)), '00', 'w') + ) + ), + 'rawOutput', json_object('stdout', 'failed output'), + 'files', json_array(json_object('path', 'apps/server/src/failed.ts')) + ) + ) + WHEN sequence IN (3, 70) THEN json_object( + 'usedTokens', sequence * 100, + 'modelContextWindow', 100000 + ) + ELSE json_object('sequence', sequence) + END, sequence, '2026-03-01T00:04:00.000Z' FROM activity_rows @@ -2382,12 +2545,14 @@ projectionSnapshotLayer("ProjectionSnapshotQuery windowed thread detail", (it) = const detailWithPinnedRequests = yield* snapshotQuery.getThreadDetailById(threadW); assert.equal(detailWithPinnedRequests._tag, "Some"); if (detailWithPinnedRequests._tag === "Some") { - const ids = detailWithPinnedRequests.value.activities.map((activity) => activity.id); + const ids = new Set( + detailWithPinnedRequests.value.activities.map((activity) => activity.id), + ); assert.equal(detailWithPinnedRequests.value.activities.length, 503); - assert.equal(ids.includes(asEventId("approval-old")), true); - assert.equal(ids.includes(asEventId("user-input-old")), true); - assert.equal(ids.includes(asEventId("user-input-closed")), false); - assert.equal(ids.includes(asEventId("user-input-tied-z-request")), true); + assert.equal(ids.has(asEventId("approval-old")), true); + assert.equal(ids.has(asEventId("user-input-old")), true); + assert.equal(ids.has(asEventId("user-input-closed")), false); + assert.equal(ids.has(asEventId("user-input-tied-z-request")), true); } const windowWithPinnedRequests = yield* snapshotQuery.getThreadDetailSnapshot(threadW, { @@ -2395,12 +2560,67 @@ projectionSnapshotLayer("ProjectionSnapshotQuery windowed thread detail", (it) = }); assert.equal(windowWithPinnedRequests._tag, "Some"); if (windowWithPinnedRequests._tag === "Some") { - const ids = windowWithPinnedRequests.value.thread.activities.map((activity) => activity.id); + const ids = new Set( + windowWithPinnedRequests.value.thread.activities.map((activity) => activity.id), + ); assert.equal(windowWithPinnedRequests.value.thread.activities.length, 503); - assert.equal(ids.includes(asEventId("approval-old")), true); - assert.equal(ids.includes(asEventId("user-input-old")), true); - assert.equal(ids.includes(asEventId("user-input-closed")), false); - assert.equal(ids.includes(asEventId("user-input-tied-z-request")), true); + assert.equal(ids.has(asEventId("approval-old")), true); + assert.equal(ids.has(asEventId("user-input-old")), true); + assert.equal(ids.has(asEventId("user-input-closed")), false); + assert.equal(ids.has(asEventId("user-input-tied-z-request")), true); + } + + const fullSnapshot = yield* snapshotQuery.getThreadDetailSnapshot(threadW); + assert.equal(fullSnapshot._tag, "Some"); + if ( + detailWithPinnedRequests._tag === "Some" && + fullSnapshot._tag === "Some" && + windowWithPinnedRequests._tag === "Some" + ) { + const projectedFullSnapshot = projectThreadDetailSnapshot(fullSnapshot.value); + const projectedRawBaseline = projectThreadDetailSnapshot({ + snapshotSequence: fullSnapshot.value.snapshotSequence, + thread: detailWithPinnedRequests.value, + }); + assert.deepStrictEqual(projectedFullSnapshot, projectedRawBaseline); + + const rawActivitiesById = new Map( + detailWithPinnedRequests.value.activities.map((activity) => [activity.id, activity]), + ); + const projectedWindowSnapshot = projectThreadDetailSnapshot(windowWithPinnedRequests.value); + const projectedWindowBaseline = projectThreadDetailSnapshot({ + ...windowWithPinnedRequests.value, + thread: { + ...windowWithPinnedRequests.value.thread, + activities: windowWithPinnedRequests.value.thread.activities.map( + (activity) => rawActivitiesById.get(activity.id) ?? activity, + ), + }, + }); + assert.deepStrictEqual(projectedWindowSnapshot, projectedWindowBaseline); + + const projectedIds = new Set( + projectedFullSnapshot.thread.activities.map((activity) => activity.id), + ); + assert.equal(projectedIds.has(asEventId("activity-0002")), false); + assert.equal(projectedIds.has(asEventId("activity-0003")), false); + assert.equal(projectedIds.has(asEventId("activity-0070")), true); + + const failedCommand = projectedFullSnapshot.thread.activities.find( + (activity) => activity.id === asEventId("activity-0011"), + ); + assert.deepStrictEqual(failedCommand?.payload, { + itemType: "command_execution", + status: "failed", + data: { + item: { + command: "vp test run", + aggregatedOutput: "failed command", + }, + files: [{ path: "apps/server/src/failed.ts" }], + rawOutput: { content: "failed output" }, + }, + }); } }), ); diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index c6c5ad1d7e8c..ea808f18d3a7 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -24,6 +24,7 @@ import { type OrchestrationThreadShell, ModelSelection, ProjectId, + ThreadLinkedPullRequest, ThreadId, } from "@t3tools/contracts"; import * as Arr from "effect/Array"; @@ -56,6 +57,7 @@ import { decodeThreadDetailPageCursor, encodeThreadDetailPageCursor, } from "../threadDetailCursor.ts"; +import { projectActivityPayload } from "../ActivityPayloadProjection.ts"; import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityResolver.ts"; import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts"; import { @@ -63,6 +65,7 @@ import { type ProjectionFullThreadDiffContext, type ProjectionSnapshotCounts, type ProjectionThreadCheckpointContext, + type ProjectionThreadDetailQuery, type ProjectionSnapshotQueryShape, } from "../Services/ProjectionSnapshotQuery.ts"; @@ -73,6 +76,9 @@ const decodeThread = Schema.decodeUnknownEffect(OrchestrationThread); // activity window. Applying the limit in SQL avoids decoding an unbounded // payload_json set before the projector can enforce that invariant. const THREAD_DETAIL_ACTIVITY_LIMIT = 500; +// Snapshot payloads are decoded and projected in small sequential batches so +// one client read does not retain the raw payloads for the full activity window. +const THREAD_DETAIL_ACTIVITY_PAYLOAD_BATCH_SIZE = 25; const ProjectionProjectDbRowSchema = ProjectionProject.mapFields( Struct.assign({ defaultModelSelection: Schema.NullOr(Schema.fromJsonString(ModelSelection)), @@ -89,6 +95,7 @@ const ProjectionThreadProposedPlanDbRowSchema = ProjectionThreadProposedPlan; const ProjectionThreadDbRowSchema = ProjectionThread.mapFields( Struct.assign({ modelSelection: Schema.fromJsonString(ModelSelection), + linkedPullRequest: Schema.NullOr(Schema.fromJsonString(ThreadLinkedPullRequest)), }), ); const ProjectionThreadActivityDbRowSchema = ProjectionThreadActivity.mapFields( @@ -97,6 +104,9 @@ const ProjectionThreadActivityDbRowSchema = ProjectionThreadActivity.mapFields( sequence: Schema.NullOr(NonNegativeInt), }), ); +const ProjectionThreadActivityIdRowSchema = Schema.Struct({ + activityId: ProjectionThreadActivity.fields.activityId, +}); const ProjectionThreadSessionDbRowSchema = ProjectionThreadSession; const ProjectionCheckpointDbRowSchema = ProjectionCheckpoint.mapFields( Struct.assign({ @@ -139,6 +149,13 @@ const ProjectIdLookupInput = Schema.Struct({ const ThreadIdLookupInput = Schema.Struct({ threadId: ThreadId, }); +const ThreadActivityKindsLookupInput = Schema.Struct({ + threadId: ThreadId, + activityKinds: Schema.Array(Schema.String), +}); +const ThreadActivityIdsLookupInput = Schema.Struct({ + activityIds: Schema.Array(ProjectionThreadActivity.fields.activityId), +}); // Windowed reads order turns by the stable keyset (anchor, turn key), where // anchor is requested_at and turn key is // COALESCE(turn_id, ''). Both are event-derived, so cursors survive the @@ -342,6 +359,21 @@ function mapProposedPlanRow( }; } +function mapThreadActivityRow( + row: Schema.Schema.Type, +): OrchestrationThreadActivity { + return { + id: row.activityId, + tone: row.tone, + kind: row.kind, + summary: row.summary, + payload: row.payload, + turnId: row.turnId, + createdAt: row.createdAt, + ...(row.sequence !== null ? { sequence: row.sequence } : {}), + }; +} + function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) { return (cause: unknown): ProjectionRepositoryError => Schema.isSchemaError(cause) @@ -422,12 +454,14 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { interaction_mode AS "interactionMode", branch, worktree_path AS "worktreePath", + linked_pull_request_json AS "linkedPullRequest", latest_turn_id AS "latestTurnId", created_at AS "createdAt", updated_at AS "updatedAt", archived_at AS "archivedAt", settled_override AS "settledOverride", settled_at AS "settledAt", + unsettled_at AS "unsettledAt", snoozed_until AS "snoozedUntil", snoozed_at AS "snoozedAt", pinned_at AS "pinnedAt", @@ -458,12 +492,14 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { interaction_mode AS "interactionMode", branch, worktree_path AS "worktreePath", + linked_pull_request_json AS "linkedPullRequest", latest_turn_id AS "latestTurnId", created_at AS "createdAt", updated_at AS "updatedAt", archived_at AS "archivedAt", settled_override AS "settledOverride", settled_at AS "settledAt", + unsettled_at AS "unsettledAt", snoozed_until AS "snoozedUntil", snoozed_at AS "snoozedAt", pinned_at AS "pinnedAt", @@ -496,12 +532,14 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { interaction_mode AS "interactionMode", branch, worktree_path AS "worktreePath", + linked_pull_request_json AS "linkedPullRequest", latest_turn_id AS "latestTurnId", created_at AS "createdAt", updated_at AS "updatedAt", archived_at AS "archivedAt", settled_override AS "settledOverride", settled_at AS "settledAt", + unsettled_at AS "unsettledAt", snoozed_until AS "snoozedUntil", snoozed_at AS "snoozedAt", pinned_at AS "pinnedAt", @@ -938,12 +976,14 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { interaction_mode AS "interactionMode", branch, worktree_path AS "worktreePath", + linked_pull_request_json AS "linkedPullRequest", latest_turn_id AS "latestTurnId", created_at AS "createdAt", updated_at AS "updatedAt", archived_at AS "archivedAt", settled_override AS "settledOverride", settled_at AS "settledAt", + unsettled_at AS "unsettledAt", snoozed_until AS "snoozedUntil", snoozed_at AS "snoozedAt", pinned_at AS "pinnedAt", @@ -1045,6 +1085,86 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { `, }); + const listThreadActivityIdsByThread = SqlSchema.findAll({ + Request: ThreadIdLookupInput, + Result: ProjectionThreadActivityIdRowSchema, + execute: ({ threadId }) => + sql` + SELECT activity_id AS "activityId" + FROM projection_thread_activities + WHERE thread_id = ${threadId} + ORDER BY + sequence DESC, + created_at DESC, + activity_id DESC + LIMIT ${THREAD_DETAIL_ACTIVITY_LIMIT} + `, + }); + + const listThreadActivityRowsByIds = SqlSchema.findAll({ + Request: ThreadActivityIdsLookupInput, + Result: ProjectionThreadActivityDbRowSchema, + execute: ({ activityIds }) => + sql` + SELECT + activity_id AS "activityId", + thread_id AS "threadId", + turn_id AS "turnId", + tone, + kind, + summary, + payload_json AS "payload", + sequence, + created_at AS "createdAt" + FROM projection_thread_activities + -- The selectors already scoped these globally unique ids to the + -- thread inside this transaction. Keep this as a primary-key lookup. + WHERE ${sql.in("activity_id", activityIds)} + `, + }); + + const listThreadActivityRowsByThreadAndKinds = SqlSchema.findAll({ + Request: ThreadActivityKindsLookupInput, + Result: ProjectionThreadActivityDbRowSchema, + execute: ({ threadId, activityKinds }) => + sql` + SELECT + activity_id AS "activityId", + thread_id AS "threadId", + turn_id AS "turnId", + tone, + kind, + summary, + payload_json AS "payload", + sequence, + created_at AS "createdAt" + FROM ( + SELECT + activity_id, + thread_id, + turn_id, + tone, + kind, + summary, + payload_json, + sequence, + created_at + FROM projection_thread_activities + WHERE thread_id = ${threadId} + AND ${sql.in("kind", activityKinds)} + ORDER BY + sequence DESC, + created_at DESC, + activity_id DESC + LIMIT ${THREAD_DETAIL_ACTIVITY_LIMIT} + ) AS recent_activities + ORDER BY + sequence ASC, + created_at ASC, + activity_id ASC + `, + }); + const getThreadSessionRowByThread = SqlSchema.findOneOption({ Request: ThreadIdLookupInput, Result: ProjectionThreadSessionDbRowSchema, @@ -1253,15 +1373,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { `, }); - // Blocking request payloads must remain available even if they predate the - // recent activity window. Each CTE returns at most one unresolved row per - // request, so the merge below stays bounded by actionable work. - const listPinnedThreadActivityRowsByThread = SqlSchema.findAll({ - Request: ThreadIdLookupInput, - Result: ProjectionThreadActivityDbRowSchema, - execute: ({ threadId }) => - sql` - WITH pending_approval_requests AS ( + const pinnedThreadActivityIdsCte = (threadId: string) => sql` +pending_approval_requests AS ( SELECT request_id, thread_id FROM projection_pending_approvals WHERE thread_id = ${threadId} @@ -1325,6 +1438,17 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { WHERE request_order = 1 AND kind = 'user-input.requested' ) + `; + + // Blocking request payloads must remain available even if they predate the + // recent activity window. Each CTE returns at most one unresolved row per + // request, so the merge below stays bounded by actionable work. + const listPinnedThreadActivityRowsByThread = SqlSchema.findAll({ + Request: ThreadIdLookupInput, + Result: ProjectionThreadActivityDbRowSchema, + execute: ({ threadId }) => + sql` + WITH ${pinnedThreadActivityIdsCte(threadId)} SELECT activity.activity_id AS "activityId", activity.thread_id AS "threadId", @@ -1342,6 +1466,17 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { `, }); + const listPinnedThreadActivityIdsByThread = SqlSchema.findAll({ + Request: ThreadIdLookupInput, + Result: ProjectionThreadActivityIdRowSchema, + execute: ({ threadId }) => + sql` + WITH ${pinnedThreadActivityIdsCte(threadId)} + SELECT activity_id AS "activityId" + FROM pinned_activity_ids + `, + }); + const listThreadActivityRowsByThreadWindow = SqlSchema.findAll({ Request: ThreadTurnRangeLookupInput, Result: ProjectionThreadActivityDbRowSchema, @@ -1409,6 +1544,48 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { `, }); + const listThreadActivityIdsByThreadWindow = SqlSchema.findAll({ + Request: ThreadTurnRangeLookupInput, + Result: ProjectionThreadActivityIdRowSchema, + execute: ({ threadId, minAnchorAt, minTurnKey, beforeAnchorAt, beforeTurnKey }) => + sql` + SELECT activity_id AS "activityId" + FROM projection_thread_activities + WHERE thread_id = ${threadId} + AND ( + turn_id IN ( + SELECT turn_id FROM projection_turns + WHERE thread_id = ${threadId} + AND turn_id IS NOT NULL + AND ( + requested_at > ${minAnchorAt} + OR ( + requested_at = ${minAnchorAt} + AND turn_id >= ${minTurnKey} + ) + ) + AND ( + requested_at < ${beforeAnchorAt} + OR ( + requested_at = ${beforeAnchorAt} + AND turn_id < ${beforeTurnKey} + ) + ) + ) + OR ( + turn_id IS NULL + AND created_at >= ${minAnchorAt} + AND created_at < ${beforeAnchorAt} + ) + ) + ORDER BY + sequence DESC, + created_at DESC, + activity_id DESC + LIMIT ${THREAD_DETAIL_ACTIVITY_LIMIT} + `, + }); + const getFullThreadDiffContextRow = SqlSchema.findOneOption({ Request: FullThreadDiffContextLookupInput, Result: ProjectionFullThreadDiffContextRowSchema, @@ -1694,12 +1871,16 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { interactionMode: row.interactionMode, branch: row.branch, worktreePath: row.worktreePath, + ...(row.linkedPullRequest === null + ? {} + : { linkedPullRequest: row.linkedPullRequest }), latestTurn: latestTurnByThread.get(row.threadId) ?? null, createdAt: row.createdAt, updatedAt: row.updatedAt, archivedAt: row.archivedAt, settledOverride: row.settledOverride, settledAt: row.settledAt, + unsettledAt: row.unsettledAt, snoozedUntil: row.snoozedUntil, snoozedAt: row.snoozedAt, pinnedAt: row.pinnedAt, @@ -1901,12 +2082,16 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { interactionMode: row.interactionMode, branch: row.branch, worktreePath: row.worktreePath, + ...(row.linkedPullRequest === null + ? {} + : { linkedPullRequest: row.linkedPullRequest }), latestTurn: latestTurnByThread.get(row.threadId) ?? null, createdAt: row.createdAt, updatedAt: row.updatedAt, archivedAt: row.archivedAt, settledOverride: row.settledOverride, settledAt: row.settledAt, + unsettledAt: row.unsettledAt, snoozedUntil: row.snoozedUntil, snoozedAt: row.snoozedAt, pinnedAt: row.pinnedAt, @@ -2037,12 +2222,16 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { interactionMode: row.interactionMode, branch: row.branch, worktreePath: row.worktreePath, + ...(row.linkedPullRequest === null + ? {} + : { linkedPullRequest: row.linkedPullRequest }), latestTurn: latestTurnByThread.get(row.threadId) ?? null, createdAt: row.createdAt, updatedAt: row.updatedAt, archivedAt: row.archivedAt, settledOverride: row.settledOverride, settledAt: row.settledAt, + unsettledAt: row.unsettledAt, snoozedUntil: row.snoozedUntil, snoozedAt: row.snoozedAt, pinnedAt: row.pinnedAt, @@ -2182,12 +2371,16 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { interactionMode: row.interactionMode, branch: row.branch, worktreePath: row.worktreePath, + ...(row.linkedPullRequest === null + ? {} + : { linkedPullRequest: row.linkedPullRequest }), latestTurn: latestTurnByThread.get(row.threadId) ?? null, createdAt: row.createdAt, updatedAt: row.updatedAt, archivedAt: row.archivedAt, settledOverride: row.settledOverride, settledAt: row.settledAt, + unsettledAt: row.unsettledAt, snoozedUntil: row.snoozedUntil, snoozedAt: row.snoozedAt, pinnedAt: row.pinnedAt, @@ -2461,12 +2654,16 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { interactionMode: threadRow.value.interactionMode, branch: threadRow.value.branch, worktreePath: threadRow.value.worktreePath, + ...(threadRow.value.linkedPullRequest === null + ? {} + : { linkedPullRequest: threadRow.value.linkedPullRequest }), latestTurn: Option.isSome(latestTurnRow) ? mapLatestTurn(latestTurnRow.value) : null, createdAt: threadRow.value.createdAt, updatedAt: threadRow.value.updatedAt, archivedAt: threadRow.value.archivedAt, settledOverride: threadRow.value.settledOverride, settledAt: threadRow.value.settledAt, + unsettledAt: threadRow.value.unsettledAt, snoozedUntil: threadRow.value.snoozedUntil, snoozedAt: threadRow.value.snoozedAt, pinnedAt: threadRow.value.pinnedAt, @@ -2494,14 +2691,136 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { readonly beforeTurnKey: string; } - const getThreadDetailByIdBounded = (threadId: ThreadId, bounds: ThreadDetailBounds | undefined) => + type ThreadDetailActivityRead = + | { + readonly mode: "raw"; + readonly query?: ProjectionThreadDetailQuery; + } + | { + readonly mode: "client"; + }; + + const listProjectedThreadActivities = Effect.fn( + "ProjectionSnapshotQuery.listProjectedThreadActivities", + )(function* (threadId: ThreadId, bounds: ThreadDetailBounds | undefined) { + const [activityIdRows, pinnedActivityIdRows] = yield* Effect.all([ + (bounds === undefined + ? listThreadActivityIdsByThread({ threadId }) + : listThreadActivityIdsByThreadWindow({ threadId, ...bounds }) + ).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getThreadDetailById:listActivityIds:query", + "ProjectionSnapshotQuery.getThreadDetailById:listActivityIds:decodeRows", + ), + ), + ), + listPinnedThreadActivityIdsByThread({ threadId }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getThreadDetailById:listPinnedActivityIds:query", + "ProjectionSnapshotQuery.getThreadDetailById:listPinnedActivityIds:decodeRows", + ), + ), + ), + ]); + const activityIds = [ + ...new Set([...activityIdRows, ...pinnedActivityIdRows].map(({ activityId }) => activityId)), + ]; + const activities: OrchestrationThreadActivity[] = []; + + for ( + let offset = 0; + offset < activityIds.length; + offset += THREAD_DETAIL_ACTIVITY_PAYLOAD_BATCH_SIZE + ) { + const batchIds = activityIds.slice( + offset, + offset + THREAD_DETAIL_ACTIVITY_PAYLOAD_BATCH_SIZE, + ); + const batchRows = yield* listThreadActivityRowsByIds({ activityIds: batchIds }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getThreadDetailById:listActivityPayloadBatch:query", + "ProjectionSnapshotQuery.getThreadDetailById:listActivityPayloadBatch:decodeRows", + ), + ), + ); + for (const row of batchRows) { + activities.push(projectActivityPayload(mapThreadActivityRow(row))); + } + } + + return activities.toSorted( + (left, right) => + (left.sequence ?? -1) - (right.sequence ?? -1) || + left.createdAt.localeCompare(right.createdAt) || + left.id.localeCompare(right.id), + ); + }); + + const getThreadDetailByIdBounded = ( + threadId: ThreadId, + bounds: ThreadDetailBounds | undefined, + activityRead: ThreadDetailActivityRead = { mode: "raw" }, + ) => Effect.gen(function* () { + const activitiesEffect = + activityRead.mode === "client" + ? listProjectedThreadActivities(threadId, bounds) + : Effect.all([ + (activityRead.query?.activityKinds === undefined + ? bounds === undefined + ? listThreadActivityRowsByThread({ threadId }) + : listThreadActivityRowsByThreadWindow({ threadId, ...bounds }) + : activityRead.query.activityKinds.length === 0 + ? Effect.succeed([]) + : listThreadActivityRowsByThreadAndKinds({ + threadId, + activityKinds: activityRead.query.activityKinds, + }) + ).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getThreadDetailById:listActivities:query", + "ProjectionSnapshotQuery.getThreadDetailById:listActivities:decodeRows", + ), + ), + ), + activityRead.query?.activityKinds === undefined + ? listPinnedThreadActivityRowsByThread({ threadId }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getThreadDetailById:listPinnedActivities:query", + "ProjectionSnapshotQuery.getThreadDetailById:listPinnedActivities:decodeRows", + ), + ), + ) + : Effect.succeed([]), + ]).pipe( + Effect.map(([activityRows, pinnedActivityRows]) => + [ + ...new Map( + [...activityRows, ...pinnedActivityRows].map( + (row) => [row.activityId, row] as const, + ), + ).values(), + ] + .toSorted( + (left, right) => + (left.sequence ?? -1) - (right.sequence ?? -1) || + left.createdAt.localeCompare(right.createdAt) || + left.activityId.localeCompare(right.activityId), + ) + .map(mapThreadActivityRow), + ), + ); + const [ threadRow, messageRows, proposedPlanRows, - activityRows, - pinnedActivityRows, + activities, checkpointRows, latestTurnRow, sessionRow, @@ -2533,25 +2852,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ), ), ), - (bounds === undefined - ? listThreadActivityRowsByThread({ threadId }) - : listThreadActivityRowsByThreadWindow({ threadId, ...bounds }) - ).pipe( - Effect.mapError( - toPersistenceSqlOrDecodeError( - "ProjectionSnapshotQuery.getThreadDetailById:listActivities:query", - "ProjectionSnapshotQuery.getThreadDetailById:listActivities:decodeRows", - ), - ), - ), - listPinnedThreadActivityRowsByThread({ threadId }).pipe( - Effect.mapError( - toPersistenceSqlOrDecodeError( - "ProjectionSnapshotQuery.getThreadDetailById:listPinnedActivities:query", - "ProjectionSnapshotQuery.getThreadDetailById:listPinnedActivities:decodeRows", - ), - ), - ), + activitiesEffect, listCheckpointRowsByThread({ threadId }).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( @@ -2582,17 +2883,6 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { return Option.none(); } - const selectedActivityRows = [ - ...new Map( - [...activityRows, ...pinnedActivityRows].map((row) => [row.activityId, row] as const), - ).values(), - ].toSorted( - (left, right) => - (left.sequence ?? -1) - (right.sequence ?? -1) || - left.createdAt.localeCompare(right.createdAt) || - left.activityId.localeCompare(right.activityId), - ); - const thread = { id: threadRow.value.threadId, projectId: threadRow.value.projectId, @@ -2602,12 +2892,16 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { interactionMode: threadRow.value.interactionMode, branch: threadRow.value.branch, worktreePath: threadRow.value.worktreePath, + ...(threadRow.value.linkedPullRequest === null + ? {} + : { linkedPullRequest: threadRow.value.linkedPullRequest }), latestTurn: Option.isSome(latestTurnRow) ? mapLatestTurn(latestTurnRow.value) : null, createdAt: threadRow.value.createdAt, updatedAt: threadRow.value.updatedAt, archivedAt: threadRow.value.archivedAt, settledOverride: threadRow.value.settledOverride, settledAt: threadRow.value.settledAt, + unsettledAt: threadRow.value.unsettledAt, snoozedUntil: threadRow.value.snoozedUntil, snoozedAt: threadRow.value.snoozedAt, pinnedAt: threadRow.value.pinnedAt, @@ -2630,21 +2924,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { return message; }), proposedPlans: proposedPlanRows.map(mapProposedPlanRow), - activities: selectedActivityRows.map((row) => { - const activity = { - id: row.activityId, - tone: row.tone, - kind: row.kind, - summary: row.summary, - payload: row.payload, - turnId: row.turnId, - createdAt: row.createdAt, - }; - if (row.sequence !== null) { - return Object.assign(activity, { sequence: row.sequence }); - } - return activity; - }), + activities, checkpoints: checkpointRows.map((row) => ({ turnId: row.turnId, checkpointTurnCount: row.checkpointTurnCount, @@ -2666,8 +2946,14 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ); }); - const getThreadDetailById: ProjectionSnapshotQueryShape["getThreadDetailById"] = (threadId) => - getThreadDetailByIdBounded(threadId, undefined); + const getThreadDetailById: ProjectionSnapshotQueryShape["getThreadDetailById"] = ( + threadId, + query, + ) => + getThreadDetailByIdBounded(threadId, undefined, { + mode: "raw", + ...(query === undefined ? {} : { query }), + }); // Bounds pathological fan-out: one user turn that spawned hundreds of // subagent turns still pages in bounded chunks, at the cost of splitting the @@ -2691,7 +2977,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { .withTransaction( Effect.gen(function* () { if (window?.turnLimit === undefined) { - const thread = yield* getThreadDetailById(threadId); + const thread = yield* getThreadDetailByIdBounded(threadId, undefined, { + mode: "client", + }); if (Option.isNone(thread)) { return Option.none(); } @@ -2744,7 +3032,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ? { minAnchorAt: "", minTurnKey: "", beforeAnchorAt: "", beforeTurnKey: "" } : undefined; - const thread = yield* getThreadDetailByIdBounded(threadId, emptyBounds ?? bounds); + const thread = yield* getThreadDetailByIdBounded(threadId, emptyBounds ?? bounds, { + mode: "client", + }); if (Option.isNone(thread)) { return Option.none(); } diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index a22a7acfb705..ff246128c44c 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -678,11 +678,24 @@ describe("ProviderCommandReactor", () => { }), ); - it("generates a thread title on the first turn", async () => { + it("retries thread title generation after a transient failure", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; const seededTitle = "Please investigate reconnect failures after restar..."; - harness.generateThreadTitle.mockReturnValue(Effect.succeed({ title: "Generated title" })); + let attempts = 0; + harness.generateThreadTitle.mockReturnValue( + Effect.suspend(() => { + attempts += 1; + return attempts === 1 + ? Effect.fail( + new TextGenerationError({ + operation: "generateThreadTitle", + detail: "Claude CLI request timed out.", + }), + ) + : Effect.succeed({ title: "Generated title" }); + }), + ); await Effect.runPromise( harness.engine.dispatch({ @@ -726,6 +739,7 @@ describe("ProviderCommandReactor", () => { const readModel = await harness.readModel(); const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); expect(thread?.title).toBe("Generated title"); + expect(attempts).toBe(2); }); it("regenerates a thread title from the current conversation", async () => { @@ -2970,15 +2984,15 @@ describe("ProviderCommandReactor", () => { }); }); - it("surfaces stale provider approval request failures without faking approval resolution", async () => { + it("normalizes stale Codex approval callbacks without faking approval resolution", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; harness.respondToRequest.mockImplementation(() => Effect.fail( new ProviderAdapterRequestError({ provider: ProviderDriverKind.make("codex"), - method: "session/request_permission", - detail: "Unknown pending permission request: approval-request-1", + method: "item/requestApproval/decision", + detail: "Unknown pending Codex approval request: approval-request-1", }), ), ); @@ -3215,4 +3229,49 @@ describe("ProviderCommandReactor", () => { expect(thread?.session?.providerInstanceId).toBe(ProviderInstanceId.make("codex_work")); expect(thread?.session?.activeTurnId).toBeNull(); }); + + effectIt.effect("stops a ready provider session after automatic settlement", () => + Effect.gen(function* () { + const sessionStopped = yield* Deferred.make(); + const harness = yield* Effect.promise(() => + createHarness({ + stopSessionEffect: () => Deferred.succeed(sessionStopped, undefined).pipe(Effect.asVoid), + }), + ); + const now = "2026-01-01T00:00:00.000Z"; + + yield* harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-session-set-for-auto-settle"), + threadId: ThreadId.make("thread-1"), + session: { + threadId: ThreadId.make("thread-1"), + status: "ready", + providerName: "codex", + providerInstanceId: ProviderInstanceId.make("codex_work"), + runtimeMode: "approval-required", + activeTurnId: null, + lastError: null, + updatedAt: now, + }, + createdAt: now, + }); + const beforeSettlement = yield* Effect.promise(() => harness.readModel()); + + yield* harness.engine.dispatch({ + type: "thread.auto-settle", + commandId: CommandId.make("cmd-auto-settle-with-session"), + threadId: ThreadId.make("thread-1"), + snapshotSequence: beforeSettlement.snapshotSequence, + }); + + yield* Deferred.await(sessionStopped); + yield* Effect.promise(() => harness.drain()); + const readModel = yield* Effect.promise(() => harness.readModel()); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); + expect(thread?.settledOverride).toBe("settled"); + expect(thread?.session?.status).toBe("stopped"); + expect(thread?.session?.providerInstanceId).toBe(ProviderInstanceId.make("codex_work")); + }), + ); }); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 1c0091028add..84d472089d8d 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -22,6 +22,7 @@ import * as Equal from "effect/Equal"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Schedule from "effect/Schedule"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; @@ -60,7 +61,8 @@ type ProviderIntentEvent = Extract< | "thread.turn-interrupt-requested" | "thread.approval-response-requested" | "thread.user-input-response-requested" - | "thread.session-stop-requested"; + | "thread.session-stop-requested" + | "thread.settled"; } >; @@ -241,13 +243,15 @@ function isUnknownPendingApprovalRequestError(cause: Cause.Cause 0 ? { attachments } : {}), - modelSelection, - }); + const generated = yield* textGeneration + .generateThreadTitle({ + cwd: input.cwd, + message: input.messageText, + ...(attachments.length > 0 ? { attachments } : {}), + modelSelection, + }) + .pipe( + Effect.retry({ + times: 2, + schedule: Schedule.exponential("2 seconds"), + }), + ); if (!generated) return; const thread = yield* resolveThread(input.threadId); @@ -1480,6 +1491,24 @@ const make = Effect.gen(function* () { case "thread.session-stop-requested": yield* processSessionStopRequested(event); return; + case "thread.settled": { + const thread = yield* projectionSnapshotQuery.getThreadShellById(event.payload.threadId); + if ( + Option.isNone(thread) || + thread.value.session == null || + thread.value.session.status === "stopped" + ) { + return; + } + yield* orchestrationEngine.dispatch({ + type: "thread.session.stop", + commandId: CommandId.make(`session-stop-for-settle:${event.commandId ?? event.eventId}`), + threadId: event.payload.threadId, + createdAt: event.occurredAt, + onlyIfSettled: true, + }); + return; + } } }); @@ -1518,7 +1547,8 @@ const make = Effect.gen(function* () { event.type === "thread.turn-interrupt-requested" || event.type === "thread.approval-response-requested" || event.type === "thread.user-input-response-requested" || - event.type === "thread.session-stop-requested" + event.type === "thread.session-stop-requested" || + event.type === "thread.settled" ) { return yield* worker.enqueue(event); } diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 84858b6affe9..26332f9f8c9c 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -973,6 +973,29 @@ describe("ProviderRuntimeIngestion", () => { ); }); + it("ignores provider content deltas that cannot change thread state", async () => { + const harness = await createHarness(); + const initial = await harness.readModel(); + + for (const streamKind of ["reasoning_text", "command_output", "file_change_output"] as const) { + harness.emit({ + type: "content.delta", + eventId: asEventId(`evt-ignored-${streamKind}`), + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:00.000Z", + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-ignored"), + payload: { + streamKind, + delta: "ignored output", + }, + }); + } + + await harness.drain(); + expect(await harness.readModel()).toEqual(initial); + }); + it("maps canonical content delta/item completed into finalized assistant messages", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 7ec3a7e64243..a90010f0b6e2 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -48,6 +48,7 @@ import { canReplaceThreadTitle } from "../threadTitles.ts"; const providerTurnKey = (threadId: ThreadId, turnId: TurnId) => `${threadId}:${turnId}`; const providerTaskKey = (threadId: ThreadId, taskId: string) => `${threadId}:${taskId}`; +const TASK_TITLE_ACTIVITY_KINDS = ["task.started", "task.progress"] as const; // Fallback when the in-memory description cache no longer has the task name // (server restart, session-exit sweep, TTL/capacity eviction): earlier @@ -949,9 +950,12 @@ const make = Effect.gen(function* () { ), ); - const resolveThreadDetail = Effect.fn("resolveThreadDetail")(function* (threadId: ThreadId) { + const resolveThreadDetail = Effect.fn("resolveThreadDetail")(function* ( + threadId: ThreadId, + activityKinds: ReadonlyArray = [], + ) { return yield* projectionSnapshotQuery - .getThreadDetailById(threadId) + .getThreadDetailById(threadId, { activityKinds }) .pipe(Effect.map(Option.getOrUndefined)); }); @@ -1495,6 +1499,10 @@ const make = Effect.gen(function* () { const processRuntimeEvent = (event: ProviderRuntimeEvent) => Effect.gen(function* () { + if (event.type === "content.delta" && event.payload.streamKind !== "assistant_text") { + return; + } + const thread = yield* resolveThreadShell(event.threadId); if (!thread) return; @@ -1511,9 +1519,17 @@ const make = Effect.gen(function* () { const now = event.createdAt; const eventTurnId = toTurnId(event.turnId); const activeTurnId = thread.session?.activeTurnId ?? null; - const pendingTurnStart = yield* projectionTurnRepository.getPendingTurnStartByThreadId({ - threadId: thread.id, - }); + const pendingTurnStart = + event.type === "session.started" || + event.type === "session.state.changed" || + event.type === "session.exited" || + event.type === "thread.started" || + event.type === "turn.started" || + event.type === "turn.completed" + ? yield* projectionTurnRepository.getPendingTurnStartByThreadId({ + threadId: thread.id, + }) + : Option.none(); const hasPendingTurnStart = Option.isSome(pendingTurnStart) && thread.session?.status === "starting"; @@ -2022,7 +2038,7 @@ const make = Effect.gen(function* () { if (event.type === "task.completed") { taskTitle = yield* lookupTaskDescription(thread.id, event.payload.taskId); if (!taskTitle) { - const threadDetail = yield* getLoadedThreadDetail(); + const threadDetail = yield* resolveThreadDetail(thread.id, TASK_TITLE_ACTIVITY_KINDS); taskTitle = findTaskTitleInActivities(threadDetail?.activities, event.payload.taskId); } } diff --git a/apps/server/src/orchestration/Layers/ThreadDeletionReactor.test.ts b/apps/server/src/orchestration/Layers/ThreadDeletionReactor.test.ts index 34b1b995a3ad..f83f1dd1b9fa 100644 --- a/apps/server/src/orchestration/Layers/ThreadDeletionReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ThreadDeletionReactor.test.ts @@ -1,10 +1,35 @@ -import { ThreadId } from "@t3tools/contracts"; +import { + CommandId, + CorrelationId, + EventId, + type OrchestrationEvent, + ThreadId, +} from "@t3tools/contracts"; +import { it as effectIt } from "@effect/vitest"; import * as Cause from "effect/Cause"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; +import * as Stream from "effect/Stream"; import { describe, expect, it } from "vite-plus/test"; -import { logCleanupCauseUnlessInterrupted } from "./ThreadDeletionReactor.ts"; +import { + ProviderService, + type ProviderServiceShape, +} from "../../provider/Services/ProviderService.ts"; +import * as TerminalManager from "../../terminal/Manager.ts"; +import { + OrchestrationEngineService, + type OrchestrationEngineShape, +} from "../Services/OrchestrationEngine.ts"; +import { ThreadDeletionReactor } from "../Services/ThreadDeletionReactor.ts"; +import { + logCleanupCauseUnlessInterrupted, + ThreadDeletionReactorLive, +} from "./ThreadDeletionReactor.ts"; describe("logCleanupCauseUnlessInterrupted", () => { const threadId = ThreadId.make("thread-deletion-reactor-test"); @@ -36,3 +61,79 @@ describe("logCleanupCauseUnlessInterrupted", () => { } }); }); + +describe("ThreadDeletionReactor drain", () => { + const now = "2026-01-01T00:00:00.000Z"; + const threadId = ThreadId.make("thread-deletion-reactor-drain"); + const deletedEvent = (sequence: number): OrchestrationEvent => ({ + sequence, + eventId: EventId.make(`evt-deleted-${sequence}`), + aggregateKind: "thread", + aggregateId: threadId, + type: "thread.deleted", + occurredAt: now, + commandId: CommandId.make(`cmd-deleted-${sequence}`), + causationEventId: null, + correlationId: CorrelationId.make(`cmd-deleted-${sequence}`), + metadata: {}, + payload: { threadId, deletedAt: now }, + }); + + effectIt.effect("waits for a published deletion the subscriber has not consumed yet", () => + Effect.gen(function* () { + const stops: Array = []; + const firstCleanupDone = yield* Deferred.make(); + // The engine has already committed and published sequence 2, but the + // subscriber has not received it yet: the stream releases it on demand. + const releaseSecondEvent = yield* Deferred.make(); + const latestSequence = yield* Ref.make(0); + const engine = { + latestSequence: Ref.get(latestSequence), + streamDomainEvents: Stream.concat( + Stream.make(deletedEvent(1)), + Stream.fromEffect(Deferred.await(releaseSecondEvent)).pipe( + Stream.map(() => deletedEvent(2)), + ), + ), + } as unknown as OrchestrationEngineShape; + const providerService = { + stopSession: () => + Effect.gen(function* () { + stops.push(stops.length + 1); + if (stops.length === 1) { + yield* Deferred.succeed(firstCleanupDone, undefined); + } + }), + } as unknown as ProviderServiceShape; + const terminalManager = { + close: () => Effect.void, + } as unknown as TerminalManager.TerminalManager["Service"]; + const layer = ThreadDeletionReactorLive.pipe( + Layer.provide(Layer.succeed(ProviderService, providerService)), + Layer.provide(Layer.succeed(TerminalManager.TerminalManager, terminalManager)), + Layer.provide(Layer.succeed(OrchestrationEngineService, engine)), + ); + + yield* Effect.scoped( + Effect.gen(function* () { + const reactor = yield* ThreadDeletionReactor; + yield* reactor.start(); + yield* Deferred.await(firstCleanupDone); + + // Sequence 1 is fully cleaned and the worker queue is idle. Sequence + // 2 is committed and published but still in flight to the subscriber. + yield* Ref.set(latestSequence, 2); + const drained = yield* Effect.forkChild(reactor.drainThrough(2)); + yield* Effect.yieldNow; + yield* Effect.yieldNow; + expect(stops).toEqual([1]); + expect(drained.pollUnsafe()).toBeUndefined(); + + yield* Deferred.succeed(releaseSecondEvent, undefined); + yield* Fiber.join(drained); + expect(stops).toEqual([1, 2]); + }), + ).pipe(Effect.provide(layer)); + }), + ); +}); diff --git a/apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts b/apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts index a026f5ad81bd..14a92a5eaef5 100644 --- a/apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts +++ b/apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts @@ -4,6 +4,7 @@ import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; import { ProviderService } from "../../provider/Services/ProviderService.ts"; import * as TerminalManager from "../../terminal/Manager.ts"; @@ -80,20 +81,43 @@ const make = Effect.gen(function* () { const worker = yield* makeDrainableWorker(processThreadDeletedSafely); + // Highest event sequence the subscriber has handed to the worker. Waiting + // through a successful thread.created sequence covers every deletion that + // was ahead of that create in the engine queue; the worker drain then covers + // the in-flight cleanup. + const seenSequence = yield* SubscriptionRef.make(0); + const noteSeen = (sequence: number) => + SubscriptionRef.update(seenSequence, (seen) => Math.max(seen, sequence)); + const start: ThreadDeletionReactorShape["start"] = Effect.fn("start")(function* () { yield* forkParked( - Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) => { - if (event.type !== "thread.deleted") { - return Effect.void; - } - return worker.enqueue(event); - }), + Stream.runForEach( + orchestrationEngine.streamDomainEvents.pipe( + // Events that landed before the subscription are not replayed, so + // start the watermark at the current head instead of zero. + Stream.onStart(orchestrationEngine.latestSequence.pipe(Effect.flatMap(noteSeen))), + ), + (event) => + (event.type === "thread.deleted" ? worker.enqueue(event) : Effect.void).pipe( + Effect.andThen(noteSeen(event.sequence)), + ), + ), + ); + }); + + const drainThrough: ThreadDeletionReactorShape["drainThrough"] = Effect.fn( + "ThreadDeletionReactor.drainThrough", + )(function* (target) { + yield* SubscriptionRef.changes(seenSequence).pipe( + Stream.filter((seen) => seen >= target), + Stream.runHead, ); + yield* worker.drain; }); return { start, - drain: worker.drain, + drainThrough, } satisfies ThreadDeletionReactorShape; }); diff --git a/apps/server/src/orchestration/Normalizer.attachments.test.ts b/apps/server/src/orchestration/Normalizer.attachments.test.ts index 27a35977ffca..7385b65315cb 100644 --- a/apps/server/src/orchestration/Normalizer.attachments.test.ts +++ b/apps/server/src/orchestration/Normalizer.attachments.test.ts @@ -93,9 +93,12 @@ describe("normalizeDispatchCommand attachments", () => { expect(attachmentId.startsWith("thread-1-")).toBe(true); expect(attachmentId).not.toBe(`thread-1-${attachmentUuid}`); expect(NodeFS.existsSync(pendingPath)).toBe(true); - expect(NodeFS.existsSync(NodePath.join(config.attachmentsDir, `${attachmentId}.png`))).toBe( - true, - ); + const claimedPngPath = NodePath.join(config.attachmentsDir, `${attachmentId}.png`); + expect(NodeFS.existsSync(claimedPngPath)).toBe(true); + // A copy, not a hard link: editing the delivered file must not mutate + // the retryable pending upload. + expect(NodeFS.statSync(claimedPngPath).ino).not.toBe(NodeFS.statSync(pendingPath).ino); + expect(NodeFS.readFileSync(claimedPngPath)).toEqual(bytes); }).pipe(Effect.provide(testLayer)), ); @@ -124,6 +127,45 @@ describe("normalizeDispatchCommand attachments", () => { }).pipe(Effect.provide(testLayer)), ); + it.effect("claims uploaded documents without changing their original extension", () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const pendingId = `pending-${attachmentUuid}-pdf`; + const pendingPath = NodePath.join(config.attachmentsDir, `${pendingId}.pdf`); + NodeFS.writeFileSync(pendingPath, Buffer.from("report")); + + const imageCommand = turnStartCommand({ attachments: [] }); + if (imageCommand.type !== "thread.turn.start") { + throw new Error("Expected a thread.turn.start command."); + } + const normalized = yield* normalizeDispatchCommand({ + ...imageCommand, + message: { + ...imageCommand.message, + attachments: [ + { + type: "file", + id: pendingId, + name: "report.pdf", + mimeType: "application/pdf", + sizeBytes: 6, + }, + ], + }, + }); + if (normalized.type !== "thread.turn.start") { + throw new Error("Expected a thread.turn.start command."); + } + + const attachment = normalized.message.attachments[0]!; + expect(attachment.type).toBe("file"); + expect(attachment.id).toMatch(/^thread-1-.*-pdf$/); + const claimedPath = NodePath.join(config.attachmentsDir, `${attachment.id}.pdf`); + expect(NodeFS.readFileSync(claimedPath)).toEqual(Buffer.from("report")); + expect(NodeFS.statSync(claimedPath).ino).not.toBe(NodeFS.statSync(pendingPath).ino); + }).pipe(Effect.provide(testLayer)), + ); + it.effect("retries a failed bootstrap with a fresh thread id", () => Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; @@ -312,7 +354,7 @@ describe("normalizeDispatchCommand attachments", () => { })), }, }).pipe(Effect.flip); - expect(mismatchedType.message).toContain("image type"); + expect(mismatchedType.message).toContain("attachment type"); }).pipe(Effect.provide(testLayer)), ); }); diff --git a/apps/server/src/orchestration/Normalizer.ts b/apps/server/src/orchestration/Normalizer.ts index bd6a8f242b87..1226a6cd25d5 100644 --- a/apps/server/src/orchestration/Normalizer.ts +++ b/apps/server/src/orchestration/Normalizer.ts @@ -176,12 +176,14 @@ export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) => }); if (expectedPath !== claim.finalPath) { return yield* new OrchestrationDispatchCommandError({ - message: `Attachment '${attachment.name}' cannot be sent: image type does not match the upload.`, + message: `Attachment '${attachment.name}' cannot be sent: attachment type does not match the upload.`, }); } // Keep the pending copy until the turn succeeds. A failed thread - // bootstrap can then retry with a fresh thread id. + // bootstrap can then retry with a fresh thread id. A copy, not a + // hard link: an agent editing the delivered file in place must not + // mutate the retry source. yield* fileSystem.copyFile(claim.currentPath, claim.finalPath).pipe( Effect.mapError( (cause) => diff --git a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts index 0a00253a2285..9428e84747cd 100644 --- a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts @@ -54,6 +54,15 @@ export interface ProjectionFullThreadDiffContext { readonly toCheckpointRef: CheckpointRef | null; } +export interface ProjectionThreadDetailQuery { + /** + * Limit activities before SQLite returns and decodes their payloads. + * Any explicit filter omits pinned-request reads. An empty list also skips + * the activity query. Omit this option to preserve the full detail response. + */ + readonly activityKinds?: ReadonlyArray; +} + /** * ProjectionSnapshotQueryShape - Service API for read-model snapshots. */ @@ -168,6 +177,7 @@ export interface ProjectionSnapshotQueryShape { */ readonly getThreadDetailById: ( threadId: ThreadId, + query?: ProjectionThreadDetailQuery, ) => Effect.Effect, ProjectionRepositoryError>; /** @@ -181,6 +191,10 @@ export interface ProjectionSnapshotQueryShape { * response carries `page` metadata (see `OrchestrationThreadDetailWindow`). * Without a window the full thread is returned with no `page` field — * pagination is strictly opt-in. + * + * Activity payloads are projected for clients as they are read in small + * sequential batches. Callers still apply the full snapshot projector for + * collection-level activity pruning. */ readonly getThreadDetailSnapshot: ( threadId: ThreadId, diff --git a/apps/server/src/orchestration/Services/ThreadDeletionReactor.ts b/apps/server/src/orchestration/Services/ThreadDeletionReactor.ts index 7c6718965a63..cdbb70919a8e 100644 --- a/apps/server/src/orchestration/Services/ThreadDeletionReactor.ts +++ b/apps/server/src/orchestration/Services/ThreadDeletionReactor.ts @@ -23,10 +23,12 @@ export interface ThreadDeletionReactorShape { readonly start: () => Effect.Effect; /** - * Resolves when the internal processing queue is empty and idle. - * Intended for test use to replace timing-sensitive sleeps. + * Resolves once every thread.deleted at or before the supplied event + * sequence has been handed to the worker and the worker is empty and idle. + * A successful thread.create sequence is the fence callers use before the + * new incarnation can own runtime resources. */ - readonly drain: Effect.Effect; + readonly drainThrough: (sequence: number) => Effect.Effect; } /** diff --git a/apps/server/src/orchestration/ThreadBackgroundLiveness.test.ts b/apps/server/src/orchestration/ThreadBackgroundLiveness.test.ts index 4a4b68ced598..b4c528480fc7 100644 --- a/apps/server/src/orchestration/ThreadBackgroundLiveness.test.ts +++ b/apps/server/src/orchestration/ThreadBackgroundLiveness.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vite-plus/test"; import * as ThreadBackgroundLiveness from "./ThreadBackgroundLiveness.ts"; describe("ThreadBackgroundLiveness", () => { - it("does not let status-free progress restart an idle task", () => { + it("does not let status-free progress or metadata restart an idle task", () => { const liveness = ThreadBackgroundLiveness.make(); liveness.recordTaskLiveness({ threadId: "thread", @@ -25,6 +25,36 @@ describe("ThreadBackgroundLiveness", () => { status: undefined, kind: "progress", }); + liveness.recordTaskLiveness({ + threadId: "thread", + taskId: "task", + taskType: undefined, + status: undefined, + kind: "updated", + }); + expect(liveness.getThreadBackgroundLiveness("thread")).toBeNull(); + + liveness.recordTaskLiveness({ + threadId: "thread", + taskId: "completed-task", + taskType: undefined, + status: undefined, + kind: "started", + }); + liveness.recordTaskLiveness({ + threadId: "thread", + taskId: "completed-task", + taskType: undefined, + status: "completed", + kind: "completed", + }); + liveness.recordTaskLiveness({ + threadId: "thread", + taskId: "completed-task", + taskType: undefined, + status: undefined, + kind: "updated", + }); expect(liveness.getThreadBackgroundLiveness("thread")).toBeNull(); }); diff --git a/apps/server/src/orchestration/ThreadBackgroundLiveness.ts b/apps/server/src/orchestration/ThreadBackgroundLiveness.ts index d4d6da06dfcd..2781e4981f7c 100644 --- a/apps/server/src/orchestration/ThreadBackgroundLiveness.ts +++ b/apps/server/src/orchestration/ThreadBackgroundLiveness.ts @@ -130,10 +130,9 @@ export function make(): ThreadBackgroundLivenessService["Service"] { return; } - // Status-free progress is a description tick, not a restart. A delayed - // progress event after idle must not put the task back in the live set - // (#7128). - if (input.kind === "progress" && input.status === undefined) { + // Status-free progress and metadata updates are not restarts. A delayed + // row after idle must not put the task back in the live set (#7128). + if ((input.kind === "progress" || input.kind === "updated") && input.status === undefined) { const existing = stateByThreadId.get(input.threadId); const stillLive = existing !== undefined && diff --git a/apps/server/src/orchestration/ThreadLiveEventCoalescer.test.ts b/apps/server/src/orchestration/ThreadLiveEventCoalescer.test.ts new file mode 100644 index 000000000000..0a9915294d03 --- /dev/null +++ b/apps/server/src/orchestration/ThreadLiveEventCoalescer.test.ts @@ -0,0 +1,171 @@ +import { + EventId, + MessageId, + ThreadId, + TurnId, + type OrchestrationEvent, + type OrchestrationThreadActivity, +} from "@t3tools/contracts"; +import { it } from "@effect/vitest"; +import * as Clock from "effect/Clock"; +import * as Effect from "effect/Effect"; +import * as TestClock from "effect/testing/TestClock"; +import { describe, expect } from "vite-plus/test"; + +import { + coalesceLiveToolUpdatedEvents, + makeThreadLiveEventCoalescer, +} from "./ThreadLiveEventCoalescer.ts"; + +const threadId = ThreadId.make("thread-coalescer-test"); +const turnId = TurnId.make("turn-coalescer-test"); + +function makeToolActivity( + sequence: number, + options: { + readonly kind?: "tool.updated" | "tool.completed"; + readonly toolCallId?: string; + readonly turnId?: TurnId; + } = {}, +): OrchestrationEvent { + const { + kind = "tool.updated", + toolCallId = "call-edit", + turnId: activityTurnId = turnId, + } = options; + const activity: OrchestrationThreadActivity = { + id: EventId.make(`activity-${sequence}`), + tone: "tool", + kind, + summary: "Editing app.ts", + payload: { + itemType: "file_change", + title: "Editing app.ts", + data: toolCallId ? { toolCallId } : {}, + }, + turnId: activityTurnId, + createdAt: "2026-01-01T00:00:01.000Z", + }; + return { + sequence, + eventId: EventId.make(`event-${sequence}`), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: "2026-01-01T00:00:01.000Z", + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + type: "thread.activity-appended", + payload: { threadId, activity }, + }; +} + +function makeMessage(sequence: number): OrchestrationEvent { + return { + sequence, + eventId: EventId.make(`event-${sequence}`), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: "2026-01-01T00:00:02.000Z", + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + type: "thread.message-sent", + payload: { + threadId, + messageId: MessageId.make(`message-${sequence}`), + role: "assistant", + text: "Still working", + turnId, + streaming: false, + createdAt: "2026-01-01T00:00:02.000Z", + updatedAt: "2026-01-01T00:00:02.000Z", + }, + }; +} + +describe("ThreadLiveEventCoalescer", () => { + it("coalesces only calls with a stable toolCallId", () => { + const events = [ + makeToolActivity(1, { toolCallId: "call-a" }), + makeToolActivity(2, { toolCallId: "call-b" }), + makeToolActivity(3, { toolCallId: "call-a" }), + ]; + + expect(coalesceLiveToolUpdatedEvents(events).map((event) => event.sequence)).toEqual([2, 3]); + }); + + it("preserves parallel same-label calls without a stable toolCallId", () => { + const events = [ + makeToolActivity(1, { toolCallId: "" }), + makeToolActivity(2, { toolCallId: "" }), + makeToolActivity(3, { kind: "tool.completed", toolCallId: "" }), + ]; + + expect(coalesceLiveToolUpdatedEvents(events).map((event) => event.sequence)).toEqual([1, 2, 3]); + }); + + it("does not coalesce stable tool calls across turns", () => { + const events = [ + makeToolActivity(1, { turnId: TurnId.make("turn-old") }), + makeToolActivity(2, { turnId: TurnId.make("turn-new") }), + ]; + + expect(coalesceLiveToolUpdatedEvents(events).map((event) => event.sequence)).toEqual([1, 2]); + }); + + it("flushes a stable update run before a completion boundary", () => { + const events = [ + makeToolActivity(1), + makeToolActivity(2), + makeToolActivity(3, { kind: "tool.completed" }), + makeToolActivity(4), + ]; + + expect(coalesceLiveToolUpdatedEvents(events).map((event) => event.sequence)).toEqual([2, 3, 4]); + }); + + it.effect("flushes pending tool updates as soon as an unrelated event arrives", () => + Effect.scoped( + Effect.gen(function* () { + const coalescer = yield* makeThreadLiveEventCoalescer({ coalesceWindow: "500 millis" }); + const startedAt = yield* Clock.currentTimeMillis; + yield* Effect.forEach( + Array.from({ length: 10 }, (_, index) => index + 2), + (sequence) => + coalescer.offerAndWait({ kind: "event", event: makeToolActivity(sequence) }), + { discard: true }, + ); + yield* coalescer.offerAndWait({ kind: "event", event: makeMessage(12) }); + + expect(yield* Clock.currentTimeMillis).toBe(startedAt); + expect( + Array.from(yield* coalescer.takeAll).map((item) => + item.kind === "event" ? item.event.sequence : item.kind, + ), + ).toEqual([11, 12]); + }), + ).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("flushes pending tool updates as soon as a synchronization marker arrives", () => + Effect.scoped( + Effect.gen(function* () { + const coalescer = yield* makeThreadLiveEventCoalescer({ coalesceWindow: "500 millis" }); + const startedAt = yield* Clock.currentTimeMillis; + yield* coalescer.offerAndWait({ kind: "event", event: makeToolActivity(2) }); + yield* coalescer.offerAndWait({ kind: "event", event: makeToolActivity(3) }); + yield* coalescer.offerAndWait({ kind: "synchronized" }); + + expect(yield* Clock.currentTimeMillis).toBe(startedAt); + expect( + Array.from(yield* coalescer.takeAll).map((item) => + item.kind === "event" ? item.event.sequence : item.kind, + ), + ).toEqual([3, "synchronized"]); + }), + ).pipe(Effect.provide(TestClock.layer())), + ); +}); diff --git a/apps/server/src/orchestration/ThreadLiveEventCoalescer.ts b/apps/server/src/orchestration/ThreadLiveEventCoalescer.ts new file mode 100644 index 000000000000..8271f6a550fb --- /dev/null +++ b/apps/server/src/orchestration/ThreadLiveEventCoalescer.ts @@ -0,0 +1,207 @@ +import type { OrchestrationEvent, OrchestrationThreadStreamItem } from "@t3tools/contracts"; +import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Predicate from "effect/Predicate"; +import * as Queue from "effect/Queue"; +import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; + +import { projectActivityEvent } from "./ActivityPayloadProjection.ts"; + +const COALESCE_WINDOW = Duration.millis(50); +const MAX_PENDING_UPDATES = 512; + +export type ThreadLiveInput = + | { readonly kind: "event"; readonly event: OrchestrationEvent } + | { readonly kind: "synchronized" }; + +function isToolUpdated(event: OrchestrationEvent): boolean { + return ( + event.type === "thread.activity-appended" && event.payload.activity.kind === "tool.updated" + ); +} + +function asTrimmedString(value: unknown): string | null { + if (!Predicate.isString(value)) { + return null; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function stableToolCallIdentity(event: OrchestrationEvent): string | null { + if (event.type !== "thread.activity-appended") { + return null; + } + const payload = event.payload.activity.payload; + if (!Predicate.isObject(payload)) { + return null; + } + const data = Predicate.isObject(payload.data) ? payload.data : null; + return asTrimmedString(payload.toolCallId) ?? asTrimmedString(data?.toolCallId); +} + +/** + * Retain only the latest in-flight update for each stable tool-call id in a + * live run. Anonymous calls pass through because labels are not unique when + * tools execute in parallel. Survivors remain in sequence order. + */ +export function coalesceLiveToolUpdatedEvents( + events: ReadonlyArray, +): ReadonlyArray { + const survivors: Array = []; + let pendingUpdates: Array = []; + + const flushUpdates = () => { + const seen = new Set(); + const latestUpdates: Array = []; + for (let index = pendingUpdates.length - 1; index >= 0; index -= 1) { + const event = pendingUpdates[index]!; + const identity = stableToolCallIdentity(event); + const activity = + event.type === "thread.activity-appended" ? event.payload.activity : undefined; + const key = identity ? `${activity?.turnId ?? ""}\u0000${identity}` : null; + if (key && seen.has(key)) { + continue; + } + if (key) { + seen.add(key); + } + latestUpdates.push(event); + } + latestUpdates.reverse(); + survivors.push(...latestUpdates); + pendingUpdates = []; + }; + + for (const event of events) { + if (isToolUpdated(event)) { + pendingUpdates.push(event); + continue; + } + flushUpdates(); + survivors.push(event); + } + flushUpdates(); + return survivors; +} + +export const makeThreadLiveEventCoalescer = Effect.fn("makeThreadLiveEventCoalescer")( + function* (options?: { readonly coalesceWindow?: Duration.Input }) { + const output = yield* Queue.unbounded(); + const input = yield* Queue.unbounded<{ + readonly value: ThreadLiveInput; + readonly processed?: Deferred.Deferred; + }>(); + const mutex = yield* Semaphore.make(1); + const coalesceWindow = options?.coalesceWindow ?? COALESCE_WINDOW; + let pendingUpdates: Array = []; + let windowGeneration = 0; + let windowFiber: Fiber.Fiber | null = null; + + const cancelWindow = Effect.fn("ThreadLiveEventCoalescer.cancelWindow")(function* () { + const fiber = windowFiber; + if (!fiber) { + return; + } + windowFiber = null; + yield* Fiber.interrupt(fiber); + }); + + const flushPending = Effect.fn("ThreadLiveEventCoalescer.flushPending")(function* ( + boundary?: OrchestrationEvent, + ) { + const events = boundary ? [...pendingUpdates, boundary] : pendingUpdates; + pendingUpdates = []; + if (events.length === 0) { + return; + } + yield* Queue.offerAll( + output, + coalesceLiveToolUpdatedEvents(events).map((event) => ({ + kind: "event" as const, + event: projectActivityEvent(event), + })), + ); + }); + + const flushWindow = (generation: number) => + Effect.sleep(coalesceWindow).pipe( + Effect.andThen( + mutex.withPermits(1)( + Effect.suspend(() => (generation === windowGeneration ? flushPending() : Effect.void)), + ), + ), + Effect.ensuring( + Effect.sync(() => { + if (generation === windowGeneration) { + windowFiber = null; + } + }), + ), + ); + + const process = Effect.fn("ThreadLiveEventCoalescer.process")(function* ( + input: ThreadLiveInput, + ) { + yield* mutex.withPermits(1)( + Effect.gen(function* () { + if (input.kind === "event" && isToolUpdated(input.event)) { + pendingUpdates.push(input.event); + if (pendingUpdates.length === 1) { + const generation = ++windowGeneration; + windowFiber = yield* Effect.forkScoped(flushWindow(generation)); + } + if (pendingUpdates.length >= MAX_PENDING_UPDATES) { + yield* cancelWindow(); + windowGeneration += 1; + yield* flushPending(); + } + return; + } + + yield* cancelWindow(); + windowGeneration += 1; + // A non-update event closes the run immediately. The coalescer keeps + // that boundary after the final update from the run. + if (input.kind === "event") { + yield* flushPending(input.event); + } else { + yield* flushPending(); + yield* Queue.offer(output, { kind: "synchronized" }); + } + }), + ); + }); + + yield* Stream.fromQueue(input).pipe( + Stream.runForEach(({ value, processed }) => + process(value).pipe( + Effect.andThen(processed ? Deferred.succeed(processed, undefined) : Effect.void), + ), + ), + Effect.forkScoped, + ); + + const offer = (value: ThreadLiveInput) => Queue.offer(input, { value }).pipe(Effect.asVoid); + + // Synchronization callers wait for their marker to pass through the same + // ordered input queue before draining output produced ahead of it. + const offerAndWait = Effect.fn("ThreadLiveEventCoalescer.offerAndWait")(function* ( + value: ThreadLiveInput, + ) { + const processed = yield* Deferred.make(); + yield* Queue.offer(input, { value, processed }); + yield* Deferred.await(processed); + }); + + return { + offer, + offerAndWait, + stream: Stream.fromQueue(output), + takeAll: Queue.takeAll(output), + } as const; + }, +); diff --git a/apps/server/src/orchestration/ThreadSettlementPolicy.test.ts b/apps/server/src/orchestration/ThreadSettlementPolicy.test.ts new file mode 100644 index 000000000000..08d2d2af24af --- /dev/null +++ b/apps/server/src/orchestration/ThreadSettlementPolicy.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, it } from "vite-plus/test"; +import { + ProviderInstanceId, + ThreadId, + ProjectId, + TurnId, + type OrchestrationThreadShell, +} from "@t3tools/contracts"; +import { shouldAutoSettleThread } from "./ThreadSettlementPolicy.ts"; + +const NOW = "2026-08-28T12:00:00.000Z"; +const makeThread = ( + overrides: Partial = {}, +): OrchestrationThreadShell => ({ + id: ThreadId.make("thread-1"), + projectId: ProjectId.make("project-1"), + title: "Thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: "feature", + worktreePath: "/repo", + latestTurn: null, + createdAt: "2026-08-01T00:00:00.000Z", + updatedAt: "2026-08-20T00:00:00.000Z", + archivedAt: null, + settledOverride: null, + settledAt: null, + session: null, + latestUserMessageAt: "2026-08-20T00:00:00.000Z", + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + ...overrides, +}); + +const decide = ( + thread: OrchestrationThreadShell, + pullRequest: { state: "open" | "closed" | "merged"; updatedAt: string | null } | null = null, + settings: { days?: number | null; merge?: boolean } = {}, +) => + shouldAutoSettleThread({ + thread, + pullRequest, + now: NOW, + autoSettleAfterDays: settings.days === undefined ? 3 : settings.days, + autoSettleOnMerge: settings.merge ?? true, + }); + +describe("shouldAutoSettleThread", () => { + it("settles inactive threads and leaves never-used threads active", () => { + expect(decide(makeThread())).toBe(true); + expect(decide(makeThread({ latestUserMessageAt: null }))).toBe(false); + expect(decide(makeThread(), null, { days: null })).toBe(false); + }); + + it("keeps a thread active at the exact inactivity boundary", () => { + expect(decide(makeThread({ latestUserMessageAt: "2026-08-25T12:00:00.000Z" }))).toBe(false); + }); + + it("keeps open pull requests active", () => { + expect(decide(makeThread(), { state: "open", updatedAt: NOW })).toBe(false); + }); + + it("settles closed requests and honors the merge setting", () => { + expect(decide(makeThread(), { state: "closed", updatedAt: NOW }, { merge: false })).toBe(true); + expect(decide(makeThread(), { state: "merged", updatedAt: NOW }, { merge: false })).toBe(true); + expect( + decide(makeThread(), { state: "merged", updatedAt: NOW }, { merge: false, days: null }), + ).toBe(false); + }); + + it("does not settle again after user activity newer than the PR", () => { + expect( + decide( + makeThread({ latestUserMessageAt: "2026-08-27T00:00:00.000Z" }), + { state: "merged", updatedAt: "2026-08-26T00:00:00.000Z" }, + { days: null }, + ), + ).toBe(false); + }); + + it("does not inherit a terminal pull request older than the thread", () => { + expect( + decide( + makeThread({ createdAt: "2026-08-20T00:00:00.000Z", latestUserMessageAt: null }), + { state: "closed", updatedAt: "2026-08-19T00:00:00.000Z" }, + { days: null }, + ), + ).toBe(false); + }); + + it("requires a comparable PR timestamp for immediate settlement", () => { + const recentThread = makeThread({ latestUserMessageAt: "2026-08-27T00:00:00.000Z" }); + expect(decide(recentThread, { state: "closed", updatedAt: null })).toBe(false); + expect(decide(recentThread, { state: "merged", updatedAt: "unknown" })).toBe(false); + expect(decide(makeThread(), { state: "closed", updatedAt: null })).toBe(true); + }); + + it("uses user request time instead of completion time as the PR anchor", () => { + const thread = makeThread({ + latestTurn: { + turnId: TurnId.make("turn-1"), + state: "completed", + requestedAt: "2026-08-25T00:00:00.000Z", + startedAt: "2026-08-25T00:01:00.000Z", + completedAt: "2026-08-27T00:00:00.000Z", + assistantMessageId: null, + }, + }); + expect(decide(thread, { state: "merged", updatedAt: "2026-08-26T00:00:00.000Z" })).toBe(true); + }); + + it("blocks pins, snooze, pending work, live sessions, and queued starts", () => { + expect(decide(makeThread({ settledOverride: "active" }))).toBe(false); + expect(decide(makeThread({ snoozedUntil: "2026-08-29T00:00:00.000Z" }))).toBe(false); + expect(decide(makeThread({ hasPendingApprovals: true }))).toBe(false); + expect(decide(makeThread({ hasPendingUserInput: true }))).toBe(false); + expect(decide(makeThread({ backgroundLiveness: "working" }))).toBe(false); + expect(decide(makeThread({ backgroundLiveness: "monitoring" }))).toBe(false); + expect( + decide( + makeThread({ + session: { + threadId: ThreadId.make("thread-1"), + status: "running", + providerName: "codex", + runtimeMode: "full-access", + activeTurnId: TurnId.make("turn-1"), + lastError: null, + updatedAt: NOW, + }, + }), + ), + ).toBe(false); + expect( + decide(makeThread({ latestUserMessageAt: "2026-08-28T11:59:00.000Z", latestTurn: null })), + ).toBe(false); + }); + + it("allows a fresh completion to wake snooze before settlement", () => { + expect( + decide( + makeThread({ + snoozedAt: "2026-08-19T00:00:00.000Z", + snoozedUntil: "2026-08-29T00:00:00.000Z", + latestTurn: { + turnId: TurnId.make("turn-woke"), + state: "completed", + requestedAt: "2026-08-18T00:00:00.000Z", + startedAt: "2026-08-18T00:01:00.000Z", + completedAt: "2026-08-20T00:00:00.000Z", + assistantMessageId: null, + }, + }), + ), + ).toBe(true); + }); +}); diff --git a/apps/server/src/orchestration/ThreadSettlementPolicy.ts b/apps/server/src/orchestration/ThreadSettlementPolicy.ts new file mode 100644 index 000000000000..5a10307956aa --- /dev/null +++ b/apps/server/src/orchestration/ThreadSettlementPolicy.ts @@ -0,0 +1,108 @@ +import type { OrchestrationThreadShell } from "@t3tools/contracts"; + +export interface SettlementPullRequest { + readonly state: "open" | "closed" | "merged"; + readonly updatedAt: string | null; +} + +const DAY_MS = 24 * 60 * 60 * 1_000; +export const QUEUED_TURN_START_GRACE_MS = 2 * 60 * 1_000; + +function latestTimestamp(values: ReadonlyArray): string | null { + let latest: string | null = null; + let latestMs = Number.NEGATIVE_INFINITY; + for (const value of values) { + if (value == null) continue; + const valueMs = Date.parse(value); + if (valueMs > latestMs) { + latest = value; + latestMs = valueMs; + } + } + return latest; +} + +/** A recent user message stays queued until a turn adopts its timestamp. + * Absolute age bounds client clock skew in both directions and stops stale + * pre-adoption data from blocking the thread forever. */ +export function threadHasQueuedTurnStart( + thread: Pick, + now: string, +): boolean { + if (thread.latestUserMessageAt === null || thread.session?.status === "error") return false; + const messageAt = Date.parse(thread.latestUserMessageAt); + const age = Date.parse(now) - messageAt; + if (Number.isNaN(age) || Math.abs(age) > QUEUED_TURN_START_GRACE_MS) return false; + if (thread.latestTurn === null) return true; + return [ + thread.latestTurn.requestedAt, + thread.latestTurn.startedAt, + thread.latestTurn.completedAt, + ].every((value) => value == null || Date.parse(value) < messageAt); +} + +function pullRequestSettles( + thread: Pick, + pullRequest: SettlementPullRequest, + autoSettleOnMerge: boolean, +): boolean { + if (pullRequest.state !== "closed" && (pullRequest.state !== "merged" || !autoSettleOnMerge)) { + return false; + } + if (pullRequest.updatedAt === null) return false; + const userAnchor = latestTimestamp([ + thread.createdAt, + thread.latestUserMessageAt, + thread.latestTurn?.requestedAt, + ]); + if (userAnchor === null) return false; + const pullRequestAt = Date.parse(pullRequest.updatedAt); + const userAnchorAt = Date.parse(userAnchor); + if (Number.isNaN(pullRequestAt) || Number.isNaN(userAnchorAt)) return false; + return pullRequestAt >= userAnchorAt; +} + +export function shouldAutoSettleThread(input: { + readonly thread: OrchestrationThreadShell; + readonly pullRequest: SettlementPullRequest | null; + readonly now: string; + readonly autoSettleAfterDays: number | null; + readonly autoSettleOnMerge: boolean; +}): boolean { + const { thread, pullRequest } = input; + if (!isAutoSettlementCandidate(thread, input.now)) return false; + if (pullRequest !== null) { + if (pullRequestSettles(thread, pullRequest, input.autoSettleOnMerge)) return true; + if (pullRequest.state === "open") return false; + } + if (input.autoSettleAfterDays === null) return false; + const activityAt = latestTimestamp([ + thread.latestUserMessageAt, + thread.latestTurn?.requestedAt, + thread.latestTurn?.startedAt, + thread.latestTurn?.completedAt, + ]); + if (activityAt === null) return false; + return Date.parse(activityAt) < Date.parse(input.now) - input.autoSettleAfterDays * DAY_MS; +} + +/** Cheap checks that run before any source control lookup. */ +export function isAutoSettlementCandidate(thread: OrchestrationThreadShell, now: string): boolean { + if (thread.archivedAt !== null || thread.settledOverride !== null) return false; + if (thread.hasPendingApprovals || thread.hasPendingUserInput) return false; + if (thread.session?.status === "starting" || thread.session?.status === "running") return false; + if (thread.backgroundLiveness != null) return false; + if (threadHasQueuedTurnStart(thread, now)) return false; + if (thread.snoozedUntil == null || Date.parse(thread.snoozedUntil) <= Date.parse(now)) + return true; + const wokeOnError = + thread.session?.status === "error" && + (thread.snoozedAt == null || + Date.parse(thread.session.updatedAt) > Date.parse(thread.snoozedAt)); + const wokeOnCompletion = + thread.snoozedAt != null && + thread.latestTurn?.state === "completed" && + thread.latestTurn.completedAt != null && + Date.parse(thread.latestTurn.completedAt) > Date.parse(thread.snoozedAt); + return wokeOnError || wokeOnCompletion; +} diff --git a/apps/server/src/orchestration/ThreadSettlementReactor.test.ts b/apps/server/src/orchestration/ThreadSettlementReactor.test.ts new file mode 100644 index 000000000000..5d8d47109faa --- /dev/null +++ b/apps/server/src/orchestration/ThreadSettlementReactor.test.ts @@ -0,0 +1,640 @@ +import { + DEFAULT_SERVER_SETTINGS, + ProjectId, + ProviderInstanceId, + PullRequestOperationError, + ThreadId, + type OrchestrationCommand, + type OrchestrationProjectShell, + type OrchestrationShellSnapshot, + type OrchestrationThreadShell, + type PullRequestDetail, + type ServerSettings, + type ServerSettingsPatch, +} from "@t3tools/contracts"; +import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; +import { assert, describe, it } from "@effect/vitest"; +import * as Crypto from "effect/Crypto"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as PubSub from "effect/PubSub"; +import * as Queue from "effect/Queue"; +import * as Ref from "effect/Ref"; +import * as Stream from "effect/Stream"; +import { TestClock } from "effect/testing"; + +import { GitManager } from "../git/GitManager.ts"; +import { PullRequestService } from "../pullRequest/PullRequestService.ts"; +import { ServerActivation } from "../serverActivation.ts"; +import { ServerSettingsService } from "../serverSettings.ts"; +import { OrchestrationCommandInvariantError } from "./Errors.ts"; +import { + OrchestrationEngineService, + type OrchestrationEngineShape, +} from "./Services/OrchestrationEngine.ts"; +import { ProjectionSnapshotQuery } from "./Services/ProjectionSnapshotQuery.ts"; +import * as ThreadSettlementReactor from "./ThreadSettlementReactor.ts"; + +const NOW = "2026-08-28T12:00:00.000Z"; +const PROJECT_ID = ProjectId.make("settlement-project"); +const LINKED_PROJECT_ID = ProjectId.make("linked-settlement-project"); + +type AutoSettleCommand = Extract; + +const testCrypto = Crypto.make({ + randomBytes: (size) => new Uint8Array(size).fill(1), + digest: (_algorithm, data) => Effect.succeed(data), +}); + +function makeProject( + id: ProjectId = PROJECT_ID, + workspaceRoot = "/workspace/project", +): OrchestrationProjectShell { + return { + id, + title: `Project ${id}`, + workspaceRoot, + defaultModelSelection: null, + scripts: [], + createdAt: "2026-08-01T00:00:00.000Z", + updatedAt: NOW, + }; +} + +function makeThread( + id: string, + overrides: Partial = {}, +): OrchestrationThreadShell { + return { + id: ThreadId.make(id), + projectId: PROJECT_ID, + title: id, + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5", + }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + latestTurn: null, + createdAt: "2026-08-01T00:00:00.000Z", + updatedAt: "2026-08-20T00:00:00.000Z", + archivedAt: null, + settledOverride: null, + settledAt: null, + session: null, + latestUserMessageAt: "2026-08-20T00:00:00.000Z", + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + ...overrides, + }; +} + +function makeSnapshot( + threads: ReadonlyArray, + projects: ReadonlyArray = [makeProject()], +): OrchestrationShellSnapshot { + return { + snapshotSequence: 1, + projects, + threads, + updatedAt: NOW, + }; +} + +function makePullRequestDetail(input: { + readonly projectId: ProjectId; + readonly repository: string; + readonly number: number; + readonly state: "open" | "closed" | "merged"; + readonly updatedAt?: string; +}): PullRequestDetail { + return { + provider: "github", + capabilities: { + diff: true, + comment: true, + actions: [], + mergeMethods: [], + search: true, + review: { inlineComment: true, reply: true, resolve: true, verdicts: [] }, + reviewers: { request: true, listCandidates: true }, + }, + viewerPermissions: { + actions: [], + comment: true, + resolve: true, + verdicts: [], + requestReviewers: true, + }, + projectId: input.projectId, + projectTitle: "Linked project", + workspaceRoot: "/workspace/linked", + repository: input.repository, + number: input.number, + title: "Pull request", + body: "", + url: `https://example.test/${input.repository}/pull/${input.number}`, + author: null, + state: input.state, + isDraft: false, + mergeability: "mergeable", + additions: 0, + deletions: 0, + changedFiles: 0, + headBranch: "feature", + baseBranch: "main", + createdAt: "2026-08-01T00:00:00.000Z", + updatedAt: input.updatedAt ?? NOW, + mergedAt: input.state === "merged" ? (input.updatedAt ?? NOW) : null, + closedAt: input.state === "closed" ? (input.updatedAt ?? NOW) : null, + reviewers: [], + labels: [], + checks: [], + mergeCapabilities: { merge: true, squash: true, rebase: true }, + }; +} + +interface HarnessOptions { + readonly snapshot: OrchestrationShellSnapshot; + readonly settings?: ServerSettings; + readonly branchPullRequest?: GitManager["Service"]["branchPullRequest"]; + readonly pullRequestDetail?: PullRequestService["Service"]["detail"]; + readonly onDispatch?: ( + command: AutoSettleCommand, + ) => Effect.Effect; +} + +const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options: HarnessOptions) { + const activation = yield* Deferred.make(); + const snapshots = yield* Ref.make(options.snapshot); + const snapshotReadCount = yield* Ref.make(0); + const snapshotReads = yield* Queue.unbounded(); + const settings = yield* Ref.make(options.settings ?? DEFAULT_SERVER_SETTINGS); + const settingsChanges = yield* PubSub.unbounded(); + const commands = yield* Ref.make>([]); + const branchCalls = yield* Ref.make< + ReadonlyArray<{ readonly cwd: string; readonly branch: string }> + >([]); + const detailCalls = yield* Ref.make< + ReadonlyArray<{ + readonly projectId: ProjectId; + readonly repository: string; + readonly number: number; + }> + >([]); + + const updateSettings = (patch: ServerSettingsPatch) => + Effect.gen(function* () { + const next = applyServerSettingsPatch(yield* Ref.get(settings), patch); + yield* Ref.set(settings, next); + yield* PubSub.publish(settingsChanges, next); + return next; + }); + + const branchPullRequest: GitManager["Service"]["branchPullRequest"] = (input) => + Ref.update(branchCalls, (calls) => [...calls, input]).pipe( + Effect.andThen(options.branchPullRequest?.(input) ?? Effect.succeed(null)), + ); + + const pullRequestDetail: PullRequestService["Service"]["detail"] = (input) => + Ref.update(detailCalls, (calls) => [...calls, input]).pipe( + Effect.andThen( + options.pullRequestDetail?.(input) ?? + Effect.succeed( + makePullRequestDetail({ + ...input, + state: "open", + }), + ), + ), + ); + + const dispatch: OrchestrationEngineShape["dispatch"] = (command) => { + if (command.type !== "thread.auto-settle") { + return Effect.die(new Error(`Unexpected command: ${command.type}`)); + } + return Ref.update(commands, (recorded) => [...recorded, command]).pipe( + Effect.andThen(options.onDispatch?.(command) ?? Effect.void), + Effect.as({ sequence: 1 }), + ); + }; + + const serverSettings = ServerSettingsService.of({ + start: Effect.void, + ready: Effect.void, + getSettings: Ref.get(settings), + updateSettings, + streamChanges: Stream.fromPubSub(settingsChanges), + subscribeChanges: PubSub.subscribe(settingsChanges).pipe( + Effect.map((subscription) => Stream.fromSubscription(subscription)), + ), + }); + + const dependencies = Layer.mergeAll( + Layer.mock(ProjectionSnapshotQuery)({ + getShellSnapshot: () => + Ref.updateAndGet(snapshotReadCount, (count) => count + 1).pipe( + Effect.tap((count) => Queue.offer(snapshotReads, count)), + Effect.andThen(Ref.get(snapshots)), + ), + }), + Layer.mock(GitManager)({ branchPullRequest }), + Layer.mock(PullRequestService)({ detail: pullRequestDetail }), + Layer.mock(OrchestrationEngineService)({ + readEvents: () => Stream.empty, + dispatch, + streamDomainEvents: Stream.empty, + latestSequence: Effect.succeed(0), + }), + Layer.succeed(ServerSettingsService, serverSettings), + Layer.succeed(ServerActivation, Deferred.await(activation)), + Layer.succeed(Crypto.Crypto, testCrypto), + ); + + return { + activation, + snapshots, + snapshotReadCount, + snapshotReads, + commands, + branchCalls, + detailCalls, + updateSettings, + layer: ThreadSettlementReactor.layer.pipe(Layer.provide(dependencies)), + }; +}); + +const startHarness = Effect.fn("startThreadSettlementHarness")(function* ( + reactor: ThreadSettlementReactor.ThreadSettlementReactor["Service"], + activation: Deferred.Deferred, + snapshotReads: Queue.Queue, +) { + yield* reactor.start(); + yield* Deferred.succeed(activation, undefined); + yield* Queue.take(snapshotReads); + yield* reactor.drain; +}); + +describe("ThreadSettlementReactor", () => { + it.effect("starts without clients and skips protected threads before pull request lookup", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const linkedPullRequest = { + projectId: LINKED_PROJECT_ID, + repository: "owner/repository", + number: 42, + url: "https://example.test/owner/repository/pull/42", + } as const; + const skipped = [ + makeThread("pending-approval", { + branch: "skip-approval", + hasPendingApprovals: true, + }), + makeThread("snoozed", { + branch: "skip-snoozed", + snoozedUntil: "2026-08-29T00:00:00.000Z", + }), + ]; + const fixture = yield* makeHarness({ + snapshot: makeSnapshot( + [ + makeThread("inactive", { branch: "inactive-feature" }), + makeThread("closed-pr", { linkedPullRequest }), + ...skipped, + ], + [makeProject(), makeProject(LINKED_PROJECT_ID, "/workspace/linked")], + ), + branchPullRequest: () => Effect.succeed(null), + pullRequestDetail: (input) => + Effect.succeed(makePullRequestDetail({ ...input, state: "closed" })), + }); + + yield* Effect.gen(function* () { + const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor; + yield* reactor.start(); + assert.strictEqual(yield* Ref.get(fixture.snapshotReadCount), 0); + + yield* Deferred.succeed(fixture.activation, undefined); + yield* Queue.take(fixture.snapshotReads); + yield* reactor.drain; + + const commands = yield* Ref.get(fixture.commands); + assert.deepStrictEqual( + commands + .map(({ threadId, snapshotSequence }) => ({ threadId, snapshotSequence })) + .sort((left, right) => left.threadId.localeCompare(right.threadId)), + [ + { + threadId: ThreadId.make("closed-pr"), + snapshotSequence: 1, + }, + { + threadId: ThreadId.make("inactive"), + snapshotSequence: 1, + }, + ], + ); + assert.deepStrictEqual(yield* Ref.get(fixture.branchCalls), [ + { cwd: "/workspace/project", branch: "inactive-feature" }, + ]); + assert.deepStrictEqual(yield* Ref.get(fixture.detailCalls), [ + { projectId: LINKED_PROJECT_ID, repository: "owner/repository", number: 42 }, + ]); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + + it.effect("reevaluates inactivity and pull request state once per minute", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const pullRequest = yield* Ref.make<"open" | "merged">("open"); + const fixture = yield* makeHarness({ + snapshot: makeSnapshot([ + makeThread("at-boundary", { + latestUserMessageAt: "2026-08-25T12:00:00.000Z", + }), + makeThread("open-pr", { + branch: "saved-feature", + latestUserMessageAt: "2026-08-27T00:00:00.000Z", + }), + ]), + branchPullRequest: () => + Ref.get(pullRequest).pipe(Effect.map((state) => ({ state, updatedAt: NOW }))), + }); + + yield* Effect.gen(function* () { + const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor; + yield* startHarness(reactor, fixture.activation, fixture.snapshotReads); + assert.deepStrictEqual(yield* Ref.get(fixture.commands), []); + + yield* Ref.set(pullRequest, "merged"); + yield* TestClock.adjust("1 minute"); + yield* Queue.take(fixture.snapshotReads); + yield* reactor.drain; + + assert.deepStrictEqual( + (yield* Ref.get(fixture.commands)) + .map((command) => command.threadId) + .sort((left, right) => left.localeCompare(right)), + [ThreadId.make("at-boundary"), ThreadId.make("open-pr")], + ); + assert.strictEqual((yield* Ref.get(fixture.branchCalls)).length, 2); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + + it.effect("uses fresh settlement settings after lookup and ignores unrelated changes", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const state = yield* Ref.make<"merged" | "closed">("merged"); + const firstLookupStarted = yield* Deferred.make(); + const releaseFirstLookup = yield* Deferred.make(); + const laterLookupStarted = yield* Deferred.make(); + const releaseLaterLookup = yield* Deferred.make(); + const lookupCount = yield* Ref.make(0); + const fixture = yield* makeHarness({ + snapshot: makeSnapshot([makeThread("settings-thread", { branch: "saved-feature" })]), + settings: { + ...DEFAULT_SERVER_SETTINGS, + sidebarAutoSettleAfterDays: null, + sidebarAutoSettleOnMerge: true, + }, + branchPullRequest: () => + Ref.updateAndGet(lookupCount, (count) => count + 1).pipe( + Effect.tap((count) => + count === 1 + ? Deferred.succeed(firstLookupStarted, undefined) + : count === 3 + ? Deferred.succeed(laterLookupStarted, undefined) + : Effect.void, + ), + Effect.tap((count) => + count === 1 + ? Deferred.await(releaseFirstLookup) + : count === 3 + ? Deferred.await(releaseLaterLookup) + : Effect.void, + ), + Effect.andThen(Ref.get(state)), + Effect.map((pullRequestState) => ({ state: pullRequestState, updatedAt: NOW })), + ), + }); + + yield* Effect.gen(function* () { + const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor; + yield* reactor.start(); + yield* Deferred.succeed(fixture.activation, undefined); + yield* Queue.take(fixture.snapshotReads); + yield* Deferred.await(firstLookupStarted); + + yield* fixture.updateSettings({ sidebarAutoSettleOnMerge: false }); + yield* Deferred.succeed(releaseFirstLookup, undefined); + yield* Queue.take(fixture.snapshotReads); + yield* reactor.drain; + assert.deepStrictEqual(yield* Ref.get(fixture.commands), []); + assert.strictEqual(yield* Ref.get(fixture.snapshotReadCount), 2); + + yield* Ref.set(state, "closed"); + yield* fixture.updateSettings({ enableAgentBrowserAccess: false }); + yield* fixture.updateSettings({ sidebarAutoSettleAfterDays: 1 }); + yield* Deferred.await(laterLookupStarted); + yield* Deferred.succeed(releaseLaterLookup, undefined); + yield* reactor.drain; + + assert.strictEqual(yield* Ref.get(fixture.snapshotReadCount), 3); + assert.strictEqual(yield* Ref.get(lookupCount), 3); + assert.deepStrictEqual( + (yield* Ref.get(fixture.commands)).map((command) => command.threadId), + [ThreadId.make("settings-thread")], + ); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + + it.effect("keeps an unknown pull request active and continues with other candidates", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const fixture = yield* makeHarness({ + snapshot: makeSnapshot( + [ + makeThread("lookup-failed", { + linkedPullRequest: { + projectId: LINKED_PROJECT_ID, + repository: "owner/repository", + number: 9, + url: "https://example.test/owner/repository/pull/9", + }, + }), + makeThread("inactive-without-pr"), + ], + [makeProject(), makeProject(LINKED_PROJECT_ID, "/workspace/linked")], + ), + pullRequestDetail: () => + Effect.fail( + new PullRequestOperationError({ + operation: "detail", + detail: "host unavailable", + }), + ), + }); + + yield* Effect.gen(function* () { + const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor; + yield* startHarness(reactor, fixture.activation, fixture.snapshotReads); + + assert.deepStrictEqual( + (yield* Ref.get(fixture.commands)).map((command) => command.threadId), + [ThreadId.make("inactive-without-pr")], + ); + assert.strictEqual((yield* Ref.get(fixture.detailCalls)).length, 1); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + + it.effect("keeps threads active when their pull request project is unavailable", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const linkedPullRequest = { + projectId: LINKED_PROJECT_ID, + repository: "owner/repository", + number: 10, + url: "https://example.test/owner/repository/pull/10", + } as const; + const fixture = yield* makeHarness({ + snapshot: makeSnapshot( + [ + makeThread("missing-own-project", { linkedPullRequest }), + makeThread("missing-branch-project", { branch: "saved-feature" }), + ], + [makeProject(LINKED_PROJECT_ID, "/workspace/linked")], + ), + pullRequestDetail: (input) => + Effect.succeed(makePullRequestDetail({ ...input, state: "open" })), + }); + + yield* Effect.gen(function* () { + const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor; + yield* startHarness(reactor, fixture.activation, fixture.snapshotReads); + + assert.deepStrictEqual(yield* Ref.get(fixture.commands), []); + assert.deepStrictEqual(yield* Ref.get(fixture.detailCalls), [ + { projectId: LINKED_PROJECT_ID, repository: "owner/repository", number: 10 }, + ]); + assert.deepStrictEqual(yield* Ref.get(fixture.branchCalls), []); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + + it.effect("deduplicates saved-branch and linked pull request lookups within a sweep", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const linkedPullRequest = { + projectId: LINKED_PROJECT_ID, + repository: "owner/repository", + number: 77, + url: "https://example.test/owner/repository/pull/77", + } as const; + const fixture = yield* makeHarness({ + snapshot: makeSnapshot( + [ + makeThread("branch-one", { + branch: "saved-feature", + worktreePath: "/deleted/worktree-one", + }), + makeThread("branch-two", { + branch: "saved-feature", + worktreePath: "/deleted/worktree-two", + }), + makeThread("linked-one", { linkedPullRequest }), + makeThread("linked-two", { linkedPullRequest }), + ], + [ + makeProject(PROJECT_ID, "/workspace/project-root"), + makeProject(LINKED_PROJECT_ID, "/workspace/linked-root"), + ], + ), + branchPullRequest: () => Effect.succeed({ state: "closed", updatedAt: NOW }), + pullRequestDetail: (input) => + Effect.succeed(makePullRequestDetail({ ...input, state: "merged" })), + }); + + yield* Effect.gen(function* () { + const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor; + yield* startHarness(reactor, fixture.activation, fixture.snapshotReads); + + assert.deepStrictEqual(yield* Ref.get(fixture.branchCalls), [ + { cwd: "/workspace/project-root", branch: "saved-feature" }, + ]); + assert.deepStrictEqual(yield* Ref.get(fixture.detailCalls), [ + { projectId: LINKED_PROJECT_ID, repository: "owner/repository", number: 77 }, + ]); + assert.deepStrictEqual( + new Set((yield* Ref.get(fixture.commands)).map((command) => command.threadId)), + new Set([ + ThreadId.make("branch-one"), + ThreadId.make("branch-two"), + ThreadId.make("linked-one"), + ThreadId.make("linked-two"), + ]), + ); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + + it.effect("carries the snapshot guard and survives a stale dispatch rejection", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const fixture = yield* makeHarness({ + snapshot: makeSnapshot([makeThread("stale"), makeThread("next-candidate")]), + onDispatch: (command) => + command.threadId === ThreadId.make("stale") + ? Effect.fail( + new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: "thread changed after settlement evaluation", + }), + ) + : Effect.void, + }); + + yield* Effect.gen(function* () { + const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor; + yield* startHarness(reactor, fixture.activation, fixture.snapshotReads); + + const firstSweep = yield* Ref.get(fixture.commands); + assert.strictEqual( + firstSweep.find((command) => command.threadId === ThreadId.make("stale")) + ?.snapshotSequence, + 1, + ); + assert.strictEqual( + firstSweep.some((command) => command.threadId === ThreadId.make("next-candidate")), + true, + ); + + yield* fixture.updateSettings({ sidebarAutoSettleAfterDays: 4 }); + yield* Queue.take(fixture.snapshotReads); + yield* reactor.drain; + assert.strictEqual((yield* Ref.get(fixture.commands)).length, 4); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); +}); diff --git a/apps/server/src/orchestration/ThreadSettlementReactor.ts b/apps/server/src/orchestration/ThreadSettlementReactor.ts new file mode 100644 index 000000000000..fd4486a9c406 --- /dev/null +++ b/apps/server/src/orchestration/ThreadSettlementReactor.ts @@ -0,0 +1,185 @@ +import { CommandId } from "@t3tools/contracts"; +import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; +import * as Cause from "effect/Cause"; +import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schedule from "effect/Schedule"; +import type * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; + +import * as GitManager from "../git/GitManager.ts"; +import * as PullRequestService from "../pullRequest/PullRequestService.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import { forkParked } from "../serverActivation.ts"; +import * as OrchestrationEngine from "./Services/OrchestrationEngine.ts"; +import * as ProjectionSnapshotQuery from "./Services/ProjectionSnapshotQuery.ts"; +import { + isAutoSettlementCandidate, + shouldAutoSettleThread, + type SettlementPullRequest, +} from "./ThreadSettlementPolicy.ts"; + +export class ThreadSettlementReactor extends Context.Service< + ThreadSettlementReactor, + { + readonly start: () => Effect.Effect; + readonly drain: Effect.Effect; + } +>()("t3/orchestration/ThreadSettlementReactor") {} + +export const make = Effect.gen(function* () { + const engine = yield* OrchestrationEngine.OrchestrationEngineService; + const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const settingsService = yield* ServerSettings.ServerSettingsService; + const git = yield* GitManager.GitManager; + const pullRequests = yield* PullRequestService.PullRequestService; + const crypto = yield* Crypto.Crypto; + + const sweep = Effect.fn("ThreadSettlementReactor.sweep")(function* () { + const snapshot = yield* snapshots.getShellSnapshot(); + const now = DateTime.formatIso(yield* DateTime.now); + const projects = new Map(snapshot.projects.map((project) => [project.id, project])); + const candidates = snapshot.threads.filter((thread) => isAutoSettlementCandidate(thread, now)); + const lookupKey = (thread: (typeof candidates)[number]) => { + if (thread.linkedPullRequest != null) { + return JSON.stringify([ + "linked", + thread.linkedPullRequest.projectId, + thread.linkedPullRequest.repository, + thread.linkedPullRequest.number, + ]); + } + if (thread.branch === null) return JSON.stringify(["none", thread.id]); + const project = projects.get(thread.projectId); + return JSON.stringify( + project === undefined + ? ["missing-project", thread.id] + : ["branch", project.workspaceRoot, thread.branch], + ); + }; + const groups = Map.groupBy(candidates, lookupKey); + + const pullRequestFor = Effect.fn("ThreadSettlementReactor.pullRequestFor")(function* ( + thread: (typeof candidates)[number], + ) { + if (thread.linkedPullRequest != null) { + if (!projects.has(thread.linkedPullRequest.projectId)) { + return yield* Effect.die(new Error("linked pull request project not found")); + } + const detail = yield* pullRequests.detail({ + projectId: thread.linkedPullRequest.projectId, + repository: thread.linkedPullRequest.repository, + number: thread.linkedPullRequest.number, + }); + return { state: detail.state, updatedAt: detail.updatedAt } satisfies SettlementPullRequest; + } + if (thread.branch === null) return null; + const project = projects.get(thread.projectId); + if (project === undefined) { + return yield* Effect.die(new Error("thread project not found")); + } + return yield* git.branchPullRequest({ cwd: project.workspaceRoot, branch: thread.branch }); + }); + + yield* Effect.forEach( + groups.values(), + (group) => + Effect.gen(function* () { + const pullRequest = yield* pullRequestFor(group[0]!); + yield* Effect.forEach( + group, + (thread) => + Effect.gen(function* () { + const settings = yield* settingsService.getSettings; + const decisionNow = DateTime.formatIso(yield* DateTime.now); + if ( + !shouldAutoSettleThread({ + thread, + pullRequest, + now: decisionNow, + autoSettleAfterDays: settings.sidebarAutoSettleAfterDays, + autoSettleOnMerge: settings.sidebarAutoSettleOnMerge, + }) + ) { + return; + } + const uuid = yield* crypto.randomUUIDv4; + yield* engine.dispatch({ + type: "thread.auto-settle", + commandId: CommandId.make(`server:auto-settle:${thread.id}:${uuid}`), + threadId: thread.id, + snapshotSequence: snapshot.snapshotSequence, + }); + }).pipe( + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.failCause(cause) + : Effect.logWarning("automatic thread settlement skipped", { + threadId: thread.id, + cause: Cause.pretty(cause), + }), + ), + ), + { discard: true }, + ); + }).pipe( + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.failCause(cause) + : Effect.logWarning("automatic thread settlement skipped", { + threadIds: group.map((thread) => thread.id), + cause: Cause.pretty(cause), + }), + ), + ), + { concurrency: 8, discard: true }, + ); + }); + + const worker = yield* makeDrainableWorker(() => + sweep().pipe( + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.failCause(cause) + : Effect.logWarning("automatic thread settlement sweep failed", { + cause: Cause.pretty(cause), + }), + ), + ), + ); + + const start: ThreadSettlementReactor["Service"]["start"] = Effect.fn( + "ThreadSettlementReactor.start", + )(function* () { + const settingsChanges = yield* settingsService.subscribeChanges; + const initialSettings = yield* settingsService.getSettings.pipe(Effect.orDie); + let lastAfterDays = initialSettings.sidebarAutoSettleAfterDays; + let lastOnMerge = initialSettings.sidebarAutoSettleOnMerge; + yield* forkParked( + Effect.gen(function* () { + yield* worker.enqueue(undefined); + yield* worker.drain; + }).pipe(Effect.repeat(Schedule.spaced("1 minute")), Effect.asVoid), + ); + yield* forkParked( + Stream.runForEach(settingsChanges, (settings) => { + if ( + settings.sidebarAutoSettleAfterDays === lastAfterDays && + settings.sidebarAutoSettleOnMerge === lastOnMerge + ) { + return Effect.void; + } + lastAfterDays = settings.sidebarAutoSettleAfterDays; + lastOnMerge = settings.sidebarAutoSettleOnMerge; + return worker.enqueue(undefined); + }), + ); + }); + + return { start, drain: worker.drain } satisfies ThreadSettlementReactor["Service"]; +}); + +export const layer = Layer.effect(ThreadSettlementReactor, make); diff --git a/apps/server/src/orchestration/commandInvariants.test.ts b/apps/server/src/orchestration/commandInvariants.test.ts index 52aac1f0c105..9aaeba943423 100644 --- a/apps/server/src/orchestration/commandInvariants.test.ts +++ b/apps/server/src/orchestration/commandInvariants.test.ts @@ -199,4 +199,34 @@ describe("commandInvariants", () => { ), ).rejects.toThrow("already exists"); }); + + it("lets a draft retry re-create a thread id after its first attempt was deleted", async () => { + const threadId = ThreadId.make("thread-1"); + const firstAttempt = readModel.threads.find((thread) => thread.id === threadId)!; + const afterRollback: OrchestrationReadModel = { + ...readModel, + threads: readModel.threads.map((thread) => + thread.id === threadId ? { ...thread, deletedAt: now, updatedAt: now } : thread, + ), + }; + const retry: OrchestrationCommand = { + type: "thread.create", + commandId: CommandId.make("cmd-retry"), + threadId, + projectId: firstAttempt.projectId, + title: firstAttempt.title, + modelSelection: firstAttempt.modelSelection, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + branch: null, + worktreePath: null, + createdAt: now, + }; + + await expect( + Effect.runPromise( + requireThreadAbsent({ readModel: afterRollback, command: retry, threadId }), + ), + ).resolves.toBeUndefined(); + }); }); diff --git a/apps/server/src/orchestration/commandInvariants.ts b/apps/server/src/orchestration/commandInvariants.ts index b59ded77f4f4..beaad93d5eef 100644 --- a/apps/server/src/orchestration/commandInvariants.ts +++ b/apps/server/src/orchestration/commandInvariants.ts @@ -156,7 +156,11 @@ export function requireThreadAbsent(input: { readonly command: OrchestrationCommand; readonly threadId: ThreadId; }): Effect.Effect { - if (!findThreadById(input.readModel, input.threadId)) { + // Thread deletion is a soft delete and a draft keeps its client-minted id + // across retries, so only a live row blocks creation. Projectors reset the + // thread's rows when the id is created again. + const existing = findThreadById(input.readModel, input.threadId); + if (existing === undefined || existing.deletedAt !== null) { return Effect.void; } return Effect.fail( @@ -166,19 +170,3 @@ export function requireThreadAbsent(input: { ), ); } - -export function requireNonNegativeInteger(input: { - readonly commandType: OrchestrationCommand["type"]; - readonly field: string; - readonly value: number; -}): Effect.Effect { - if (Number.isInteger(input.value) && input.value >= 0) { - return Effect.void; - } - return Effect.fail( - invariantError( - input.commandType, - `${input.field} must be an integer greater than or equal to 0.`, - ), - ); -} diff --git a/apps/server/src/orchestration/decider.settled.test.ts b/apps/server/src/orchestration/decider.settled.test.ts index 20bc3475613a..e470ba33c790 100644 --- a/apps/server/src/orchestration/decider.settled.test.ts +++ b/apps/server/src/orchestration/decider.settled.test.ts @@ -5,6 +5,7 @@ import { ProjectId, ProviderInstanceId, ThreadId, + type OrchestrationEvent, type OrchestrationReadModel, type OrchestrationSession, type OrchestrationThread, @@ -14,9 +15,12 @@ import { expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import { decideOrchestrationCommand } from "./decider.ts"; +import { projectEvent } from "./projector.ts"; const NOW = "2026-01-01T00:00:00.000Z"; const SETTLED_AT = "2025-12-30T00:00:00.000Z"; +const SETTLE_BLOCKED_MESSAGE = + "This thread still needs attention. Resolve or interrupt it first, then try again."; function makeReadModel( settledOverride: OrchestrationThread["settledOverride"], @@ -77,6 +81,22 @@ function makeSession(status: OrchestrationSession["status"]): OrchestrationSessi } it.layer(NodeServices.layer)("settled thread decider", (it) => { + it.effect("rejects an automatic settle when the thread is pinned active", () => + Effect.gen(function* () { + const command = { + type: "thread.auto-settle" as const, + commandId: CommandId.make("cmd-auto-settle"), + threadId: ThreadId.make("thread-1"), + snapshotSequence: 0, + }; + const pinnedActive = yield* decideOrchestrationCommand({ + command, + readModel: makeReadModel("active"), + }).pipe(Effect.flip); + expect(pinnedActive._tag).toBe("OrchestrationCommandInvariantError"); + }), + ); + it.effect("settles awake threads without a redundant wake and re-emits idempotently", () => Effect.gen(function* () { const event = yield* decideOrchestrationCommand({ @@ -196,7 +216,11 @@ it.layer(NodeServices.layer)("settled thread decider", (it) => { }, readModel: makeReadModel(null, null, makeSession(status)), }).pipe(Effect.flip); - expect(error._tag).toBe("OrchestrationCommandInvariantError"); + expect(error).toMatchObject({ + _tag: "OrchestrationThreadSettleBlockedError", + threadId: ThreadId.make("thread-1"), + message: SETTLE_BLOCKED_MESSAGE, + }); } // Stopped/error sessions are settleable — only live work is protected. const settled = yield* decideOrchestrationCommand({ @@ -236,7 +260,11 @@ it.layer(NodeServices.layer)("settled thread decider", (it) => { requestActivity("approval.requested", "req-1", NOW), ]), }).pipe(Effect.flip); - expect(openError._tag).toBe("OrchestrationCommandInvariantError"); + expect(openError).toMatchObject({ + _tag: "OrchestrationThreadSettleBlockedError", + threadId: ThreadId.make("thread-1"), + message: SETTLE_BLOCKED_MESSAGE, + }); // Same request later resolved: settleable again. const settled = yield* decideOrchestrationCommand({ @@ -264,7 +292,11 @@ it.layer(NodeServices.layer)("settled thread decider", (it) => { requestActivity("user-input.requested", "req-2", NOW), ]), }).pipe(Effect.flip); - expect(inputError._tag).toBe("OrchestrationCommandInvariantError"); + expect(inputError).toMatchObject({ + _tag: "OrchestrationThreadSettleBlockedError", + threadId: ThreadId.make("thread-1"), + message: SETTLE_BLOCKED_MESSAGE, + }); }), ); @@ -285,8 +317,7 @@ it.layer(NodeServices.layer)("settled thread decider", (it) => { createdAt: NOW, }) as OrchestrationThread["activities"][number]; - // Stale-failure detail clears the request — mirrors the projection's - // pending accounting, which is what the client's canSettle sees. + // Stale-failure details clear the request, matching the projection flags. const settled = yield* decideOrchestrationCommand({ command: { type: "thread.settle", @@ -322,7 +353,11 @@ it.layer(NodeServices.layer)("settled thread decider", (it) => { }), ]), }).pipe(Effect.flip); - expect(stillOpen._tag).toBe("OrchestrationCommandInvariantError"); + expect(stillOpen).toMatchObject({ + _tag: "OrchestrationThreadSettleBlockedError", + threadId: ThreadId.make("thread-1"), + message: SETTLE_BLOCKED_MESSAGE, + }); }), ); @@ -350,7 +385,11 @@ it.layer(NodeServices.layer)("settled thread decider", (it) => { }, readModel: makeReadModel(null, null, null, [], [userMessage("1969-12-31T23:59:30.000Z")]), }).pipe(Effect.flip); - expect(queuedError._tag).toBe("OrchestrationCommandInvariantError"); + expect(queuedError).toMatchObject({ + _tag: "OrchestrationThreadSettleBlockedError", + threadId: ThreadId.make("thread-1"), + message: SETTLE_BLOCKED_MESSAGE, + }); // Message timestamp far in the FUTURE (client clock ahead of server): // a negative age must not read as queued forever — past the grace @@ -428,6 +467,42 @@ it.layer(NodeServices.layer)("settled thread decider", (it) => { }), ); + // Command-to-projection: an accepted un-settle must land as the re-entry + // stamp clients sort by (max of createdAt and unsettledAt, see + // activeThreadAnchorTimestampMs in client-runtime), so the thread surfaces + // above threads created after it. The projector tests feed events directly; + // this one proves the decider actually emits what they consume. + it.effect("an accepted un-settle re-anchors the thread for the active list", () => + Effect.gen(function* () { + const readModel = makeReadModel("settled"); + const result = yield* decideOrchestrationCommand({ + command: { + type: "thread.unsettle", + commandId: CommandId.make("cmd-unsettle-anchor"), + threadId: ThreadId.make("thread-1"), + reason: "user", + }, + readModel, + }); + const events = Array.isArray(result) ? result : [result]; + const unsettled = events[0]!; + expect(unsettled.type).toBe("thread.unsettled"); + + const projected = yield* projectEvent(readModel, { + ...unsettled, + sequence: readModel.snapshotSequence + 1, + } as OrchestrationEvent); + const thread = projected.threads[0]!; + expect(thread.settledOverride).toBe("active"); + // The stamp is the decider's accept time: every thread created before + // the un-settle anchors below it. + expect(thread.unsettledAt).toBe(unsettled.occurredAt); + if (unsettled.type === "thread.unsettled") { + expect(thread.unsettledAt).toBe(unsettled.payload.updatedAt); + } + }), + ); + it.effect("prepends activity unsets for turn starts and live session updates", () => Effect.gen(function* () { const turnResult = yield* decideOrchestrationCommand({ diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 4f61955fa6aa..892475f62447 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -3,13 +3,18 @@ import { type OrchestrationCommand, type OrchestrationEvent, type OrchestrationReadModel, + type OrchestrationThread, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import type * as PlatformError from "effect/PlatformError"; -import { OrchestrationCommandInvariantError } from "./Errors.ts"; +import { + OrchestrationCommandInvariantError, + OrchestrationThreadSettleBlockedError, + type OrchestrationCommandRejection, +} from "./Errors.ts"; import { listThreadsByProjectId, requireActiveProjectWorkspaceRootAbsent, @@ -21,14 +26,10 @@ import { requireThreadNotArchived, } from "./commandInvariants.ts"; import { projectEvent } from "./projector.ts"; +import { threadHasQueuedTurnStart } from "./ThreadSettlementPolicy.ts"; const nowIso = Effect.map(DateTime.now, DateTime.formatIso); -// Session adoption takes seconds; a user message still unadopted after this -// window is a failed/stale start, not pending work. Mirrors the client's -// QUEUED_TURN_START_GRACE_MS in client-runtime threadSettled.ts. -const QUEUED_TURN_START_GRACE_MS = 2 * 60 * 1_000; - /** * Blocked-on-you work derived from the thread's retained activities: an * approval or user-input request with no later resolution for the same @@ -86,59 +87,28 @@ function hasOpenBlockingRequest(thread: { return openRequestIds.size > 0; } -/** - * A queued turn start — a user message no turn has picked up yet — is work - * in flight even though session is still null (turn.start emits - * message-sent + turn-start-requested; the session arrives later). Detection - * mirrors the client's hasQueuedTurnStart: the newest user message is - * strictly newer than every latestTurn timestamp (adoption stamps the new - * turn's requestedAt with the message time, clearing this), and only within - * the adoption grace window — historical threads whose last user message - * postdates their turn timestamps (older-server data, mid-turn messages) - * must not be blocked forever. A failed session start (status "error") - * clears the block immediately. - * - * The age check is bounded on BOTH sides: message timestamps are - * client-supplied, so a client clock ahead of the server yields a negative - * age. Without the lower bound that negative age satisfies `<= grace` for - * as long as the skew lasts, extending the block far past the intended two - * minutes. - */ -function threadHasQueuedTurnStart( - thread: { - readonly messages: ReadonlyArray<{ readonly role: string; readonly createdAt: string }>; - readonly latestTurn: { - readonly requestedAt: string; - readonly startedAt: string | null; - readonly completedAt: string | null; - } | null; - readonly session: { readonly status: string } | null; - }, - occurredAt: string, +/** Apply the shared shell-level rule to the detailed command read model. */ +function hasQueuedTurnStartForThread( + thread: Pick, + now: string, ): boolean { - const latestUserMessageAtMs = thread.messages.reduce( - (latest, message) => - message.role === "user" ? Math.max(latest, Date.parse(message.createdAt)) : latest, - Number.NEGATIVE_INFINITY, - ); - const latestTurnAtMs = - thread.latestTurn === null - ? Number.NEGATIVE_INFINITY - : Math.max( - ...[ - thread.latestTurn.requestedAt, - thread.latestTurn.startedAt, - thread.latestTurn.completedAt, - ].map((candidate) => - candidate == null ? Number.NEGATIVE_INFINITY : Date.parse(candidate), - ), - ); - const queuedAgeMs = Date.parse(occurredAt) - latestUserMessageAtMs; - return ( - thread.session?.status !== "error" && - Number.isFinite(latestUserMessageAtMs) && - latestUserMessageAtMs > latestTurnAtMs && - Math.abs(queuedAgeMs) <= QUEUED_TURN_START_GRACE_MS + let latestUserMessageAt: string | null = null; + let latestUserMessageAtMs = Number.NEGATIVE_INFINITY; + for (const message of thread.messages) { + if (message.role !== "user") continue; + const messageAtMs = Date.parse(message.createdAt); + latestUserMessageAtMs = Math.max(latestUserMessageAtMs, messageAtMs); + if (messageAtMs === latestUserMessageAtMs) { + latestUserMessageAt = message.createdAt; + } + } + return threadHasQueuedTurnStart( + { + latestUserMessageAt: Number.isFinite(latestUserMessageAtMs) ? latestUserMessageAt : null, + latestTurn: thread.latestTurn, + session: thread.session, + }, + now, ); } @@ -186,7 +156,7 @@ const decideCommandSequence = Effect.fn("decideCommandSequence")(function* ({ readonly readModel: OrchestrationReadModel; }): Effect.fn.Return< ReadonlyArray, - OrchestrationCommandInvariantError | PlatformError.PlatformError, + OrchestrationCommandRejection | PlatformError.PlatformError, Crypto.Crypto > { let nextReadModel = readModel; @@ -220,7 +190,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" readonly readModel: OrchestrationReadModel; }): Effect.fn.Return< DecideOrchestrationCommandResult, - OrchestrationCommandInvariantError | PlatformError.PlatformError, + OrchestrationCommandRejection | PlatformError.PlatformError, Crypto.Crypto > { switch (command.type) { @@ -450,43 +420,36 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }; } - case "thread.settle": { + case "thread.settle": + case "thread.auto-settle": { const thread = yield* requireThreadNotArchived({ readModel, command, threadId: command.threadId, }); - // Server-side twin of the client's canSettle session check: a stale - // or raced client must not settle a thread whose session is coming - // alive or working. - if (thread.session?.status === "starting" || thread.session?.status === "running") { + if (command.type === "thread.auto-settle" && thread.settledOverride !== null) { return yield* Effect.fail( new OrchestrationCommandInvariantError({ commandType: command.type, - detail: `thread ${command.threadId} has an active session and cannot be settled`, + detail: `thread ${command.threadId} changed before automatic settlement`, }), ); } + // The server owns settle eligibility. A stale command must not settle + // a thread whose session is coming alive or working. + if (thread.session?.status === "starting" || thread.session?.status === "running") { + return yield* new OrchestrationThreadSettleBlockedError({ threadId: command.threadId }); + } // Pending approval / user-input requests are blocked-on-you work: a // raced or stale client must not park them behind a settled override // that would surface only after the request resolves. if (hasOpenBlockingRequest(thread)) { - return yield* Effect.fail( - new OrchestrationCommandInvariantError({ - commandType: command.type, - detail: `thread ${command.threadId} has a pending approval or user-input request and cannot be settled`, - }), - ); + return yield* new OrchestrationThreadSettleBlockedError({ threadId: command.threadId }); } const occurredAt = yield* nowIso; // Settling inside the adoption window would hide just-requested work. - if (threadHasQueuedTurnStart(thread, occurredAt)) { - return yield* Effect.fail( - new OrchestrationCommandInvariantError({ - commandType: command.type, - detail: `thread ${command.threadId} has a queued turn start and cannot be settled`, - }), - ); + if (hasQueuedTurnStartForThread(thread, occurredAt)) { + return yield* new OrchestrationThreadSettleBlockedError({ threadId: command.threadId }); } // Settling an already-settled thread re-emits with the original // settledAt: the engine rejects zero-event commands, and bulk-settle / @@ -610,7 +573,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" // invisible pending work: no session, no pending flags. Snoozing in // that window would hide a just-requested turn exactly the way settle // would. - if (threadHasQueuedTurnStart(thread, occurredAt)) { + if (hasQueuedTurnStartForThread(thread, occurredAt)) { return yield* Effect.fail( new OrchestrationCommandInvariantError({ commandType: command.type, @@ -847,6 +810,9 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" : {}), ...(branch !== undefined ? { branch } : {}), ...(command.worktreePath !== undefined ? { worktreePath: command.worktreePath } : {}), + ...(command.linkedPullRequest !== undefined + ? { linkedPullRequest: command.linkedPullRequest } + : {}), updatedAt: occurredAt, }, }; @@ -1149,7 +1115,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" if ( thread.settledOverride !== "settled" || sessionComingAlive || - threadHasQueuedTurnStart(thread, command.createdAt) + hasQueuedTurnStartForThread(thread, command.createdAt) ) { return yield* Effect.fail( new OrchestrationCommandInvariantError({ diff --git a/apps/server/src/orchestration/projector.settled.test.ts b/apps/server/src/orchestration/projector.settled.test.ts index 2070c44418a4..7c9395e6d2bd 100644 --- a/apps/server/src/orchestration/projector.settled.test.ts +++ b/apps/server/src/orchestration/projector.settled.test.ts @@ -62,27 +62,62 @@ it.effect("projects settled lifecycle events", () => ); expect(settled.threads[0]?.settledOverride).toBe("settled"); expect(settled.threads[0]?.settledAt).toBe(now); + expect(settled.threads[0]?.unsettledAt).toBeNull(); + const unsettleAt = "2026-01-02T00:00:00.000Z"; const userUnsettled = yield* projectEvent( settled, makeEvent({ sequence: 3, type: "thread.unsettled", - payload: { threadId: ThreadId.make("thread-1"), reason: "user", updatedAt: now }, + payload: { threadId: ThreadId.make("thread-1"), reason: "user", updatedAt: unsettleAt }, }), ); expect(userUnsettled.threads[0]?.settledOverride).toBe("active"); expect(userUnsettled.threads[0]?.settledAt).toBeNull(); + expect(userUnsettled.threads[0]?.unsettledAt).toBe(unsettleAt); + // Clearing the keep-active pin on activity is not a re-entry: the thread + // is already in the active list, so the stamp must not move it. + const activityAt = "2026-01-03T00:00:00.000Z"; const activityUnsettled = yield* projectEvent( userUnsettled, makeEvent({ sequence: 4, type: "thread.unsettled", - payload: { threadId: ThreadId.make("thread-1"), reason: "activity", updatedAt: now }, + payload: { threadId: ThreadId.make("thread-1"), reason: "activity", updatedAt: activityAt }, }), ); expect(activityUnsettled.threads[0]?.settledOverride).toBeNull(); expect(activityUnsettled.threads[0]?.settledAt).toBeNull(); + expect(activityUnsettled.threads[0]?.unsettledAt).toBe(unsettleAt); + + const resettledAt = "2026-01-04T00:00:00.000Z"; + const resettled = yield* projectEvent( + activityUnsettled, + makeEvent({ + sequence: 5, + type: "thread.settled", + payload: { + threadId: ThreadId.make("thread-1"), + settledAt: resettledAt, + updatedAt: resettledAt, + }, + }), + ); + expect(resettled.threads[0]?.unsettledAt).toBeNull(); + + // Waking a settled thread on activity IS a re-entry and stamps. + const wakeAt = "2026-01-05T00:00:00.000Z"; + const woke = yield* projectEvent( + resettled, + makeEvent({ + sequence: 6, + type: "thread.unsettled", + payload: { threadId: ThreadId.make("thread-1"), reason: "activity", updatedAt: wakeAt }, + }), + ); + expect(woke.threads[0]?.settledOverride).toBeNull(); + expect(woke.threads[0]?.unsettledAt).toBe(wakeAt); }), ); diff --git a/apps/server/src/orchestration/projector.test.ts b/apps/server/src/orchestration/projector.test.ts index 9c07a312023c..dad3d07370f9 100644 --- a/apps/server/src/orchestration/projector.test.ts +++ b/apps/server/src/orchestration/projector.test.ts @@ -91,6 +91,7 @@ describe("orchestration projector", () => { archivedAt: null, settledOverride: null, settledAt: null, + unsettledAt: null, snoozedUntil: null, snoozedAt: null, deletedAt: null, diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index f486dcb2bcbc..1c4cd65d5123 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -303,6 +303,7 @@ export function projectEvent( archivedAt: null, settledOverride: null, settledAt: null, + unsettledAt: null, snoozedUntil: null, snoozedAt: null, deletedAt: null, @@ -364,6 +365,7 @@ export function projectEvent( threads: updateThread(nextBase.threads, payload.threadId, { settledOverride: "settled", settledAt: payload.settledAt, + unsettledAt: null, updatedAt: payload.updatedAt, }), })), @@ -371,14 +373,24 @@ export function projectEvent( case "thread.unsettled": return decodeForEvent(ThreadUnsettledPayload, event.payload, event.type, "payload").pipe( - Effect.map((payload) => ({ - ...nextBase, - threads: updateThread(nextBase.threads, payload.threadId, { - settledOverride: payload.reason === "user" ? "active" : null, - settledAt: null, - updatedAt: payload.updatedAt, - }), - })), + Effect.map((payload) => { + const existing = nextBase.threads.find((thread) => thread.id === payload.threadId); + return { + ...nextBase, + threads: updateThread(nextBase.threads, payload.threadId, { + settledOverride: payload.reason === "user" ? "active" : null, + settledAt: null, + // Re-entry stamp for active-list ordering. A thread already + // pinned active keeps its stamp: the activity reset that clears + // the pin is not a re-entry and must not reorder the list. + unsettledAt: + existing?.settledOverride === "active" + ? (existing.unsettledAt ?? null) + : payload.updatedAt, + updatedAt: payload.updatedAt, + }), + }; + }), ); case "thread.snoozed": @@ -456,6 +468,9 @@ export function projectEvent( : {}), ...(payload.branch !== undefined ? { branch: payload.branch } : {}), ...(payload.worktreePath !== undefined ? { worktreePath: payload.worktreePath } : {}), + ...(payload.linkedPullRequest !== undefined + ? { linkedPullRequest: payload.linkedPullRequest } + : {}), updatedAt: payload.updatedAt, }), })), diff --git a/apps/server/src/pathExpansion.ts b/apps/server/src/pathExpansion.ts index bacdaece0b1c..ec3f03faa5b8 100644 --- a/apps/server/src/pathExpansion.ts +++ b/apps/server/src/pathExpansion.ts @@ -2,6 +2,8 @@ import * as NodeOS from "node:os"; import * as NodePath from "node:path"; +import type * as Path from "effect/Path"; + /** * Expand a leading `~` (or `~/…`, `~\…`) in a user-supplied path to the * current user's home directory. Spawned processes don't get shell @@ -22,3 +24,19 @@ export function expandHomePath(value: string): string { } return value; } + +/** + * Same expansion as `expandHomePath`, but joins with a caller-supplied + * `Path.Path` service instead of `node:path`. Use this inside Effect code that + * already has `Path.Path` in context so the platform layer stays in control of + * separator handling. + */ +export function expandHomePathWith(value: string, path: Path.Path): string { + if (value === "~") { + return NodeOS.homedir(); + } + if (value.startsWith("~/") || value.startsWith("~\\")) { + return path.join(NodeOS.homedir(), value.slice(2)); + } + return value; +} diff --git a/apps/server/src/persistence/Layers/OrchestrationEventStore.test.ts b/apps/server/src/persistence/Layers/OrchestrationEventStore.test.ts index 2bac5de920cb..1e21501e4096 100644 --- a/apps/server/src/persistence/Layers/OrchestrationEventStore.test.ts +++ b/apps/server/src/persistence/Layers/OrchestrationEventStore.test.ts @@ -17,7 +17,7 @@ const layer = it.layer( ); layer("OrchestrationEventStore", (it) => { - it.effect("stores json columns as strings and replays decoded events", () => + it.effect("stores json columns as strings and replays CLI-origin events", () => Effect.gen(function* () { const eventStore = yield* OrchestrationEventStore; const sql = yield* SqlClient.SqlClient; @@ -34,6 +34,9 @@ layer("OrchestrationEventStore", (it) => { correlationId: CommandId.make("cmd-store-roundtrip"), metadata: { adapterKey: "codex", + origin: { + surface: "cli", + }, }, payload: { projectId: ProjectId.make("project-roundtrip"), @@ -66,6 +69,7 @@ layer("OrchestrationEventStore", (it) => { assert.equal(replayed.length, 1); assert.equal(replayed[0]?.type, "project.created"); assert.equal(replayed[0]?.metadata.adapterKey, "codex"); + assert.deepEqual(replayed[0]?.metadata.origin, { surface: "cli" }); }), ); diff --git a/apps/server/src/persistence/Layers/OrchestrationEventStore.ts b/apps/server/src/persistence/Layers/OrchestrationEventStore.ts index 18d0e9aa578b..e801c34af582 100644 --- a/apps/server/src/persistence/Layers/OrchestrationEventStore.ts +++ b/apps/server/src/persistence/Layers/OrchestrationEventStore.ts @@ -15,6 +15,7 @@ import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as SqlSchema from "effect/unstable/sql/SqlSchema"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; @@ -60,6 +61,13 @@ const OrchestrationEventPersistedRowSchema = Schema.Struct({ metadata: EventMetadataFromJsonString, }); +const HasEventAfterRequestSchema = Schema.Struct({ + aggregateKind: Schema.String, + aggregateId: Schema.String, + type: Schema.optional(Schema.String), + sequenceExclusive: NonNegativeInt, +}); + const ReadFromSequenceRequestSchema = Schema.Struct({ sequenceExclusive: NonNegativeInt, limit: Schema.Number, @@ -260,10 +268,38 @@ const makeEventStore = Effect.gen(function* () { return readPage(sequenceExclusive, normalizedLimit); }; + const findEventAfter = SqlSchema.findOneOption({ + Request: HasEventAfterRequestSchema, + Result: Schema.Struct({ sequence: Schema.Number }), + execute: (request) => sql` + SELECT sequence + FROM orchestration_events + WHERE aggregate_kind = ${request.aggregateKind} + AND stream_id = ${request.aggregateId} + AND ${sql.and([ + sql`sequence > ${request.sequenceExclusive}`, + ...(request.type === undefined ? [] : [sql`event_type = ${request.type}`]), + ])} + LIMIT 1 + `, + }); + + const hasEventAfter: OrchestrationEventStoreShape["hasEventAfter"] = (input) => + findEventAfter(input).pipe( + Effect.map(Option.isSome), + Effect.mapError( + toPersistenceSqlOrDecodeError( + "OrchestrationEventStore.hasEventAfter:query", + "OrchestrationEventStore.hasEventAfter:decodeRow", + ), + ), + ); + return { append, readFromSequence, readAll: () => readFromSequence(0, Number.MAX_SAFE_INTEGER), + hasEventAfter, } satisfies OrchestrationEventStoreShape; }); diff --git a/apps/server/src/persistence/Layers/ProjectionPendingApprovals.ts b/apps/server/src/persistence/Layers/ProjectionPendingApprovals.ts index 253f6e13b977..3b159a9e1715 100644 --- a/apps/server/src/persistence/Layers/ProjectionPendingApprovals.ts +++ b/apps/server/src/persistence/Layers/ProjectionPendingApprovals.ts @@ -95,6 +95,15 @@ const makeProjectionPendingApprovalRepository = Effect.gen(function* () { `, }); + const deleteProjectionPendingApprovalRowsByThread = SqlSchema.void({ + Request: ListProjectionPendingApprovalsInput, + execute: ({ threadId }) => + sql` + DELETE FROM projection_pending_approvals + WHERE thread_id = ${threadId} + `, + }); + const upsert: ProjectionPendingApprovalRepositoryShape["upsert"] = (row) => upsertProjectionPendingApprovalRow(row).pipe( Effect.mapError(toPersistenceSqlError("ProjectionPendingApprovalRepository.upsert:query")), @@ -123,11 +132,19 @@ const makeProjectionPendingApprovalRepository = Effect.gen(function* () { ), ); + const deleteByThreadId: ProjectionPendingApprovalRepositoryShape["deleteByThreadId"] = (input) => + deleteProjectionPendingApprovalRowsByThread(input).pipe( + Effect.mapError( + toPersistenceSqlError("ProjectionPendingApprovalRepository.deleteByThreadId:query"), + ), + ); + return { upsert, listByThreadId, getByRequestId, deleteByRequestId, + deleteByThreadId, } satisfies ProjectionPendingApprovalRepositoryShape; }); diff --git a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts index bebd8fbb4a7d..70a034932089 100644 --- a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts +++ b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts @@ -94,6 +94,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { archivedAt: null, settledOverride: null, settledAt: null, + unsettledAt: null, snoozedUntil: null, snoozedAt: null, pinnedAt: null, @@ -157,6 +158,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { archivedAt: null, settledOverride: "settled", settledAt: "2026-03-25T00:00:00.000Z", + unsettledAt: null, snoozedUntil: "2026-03-26T09:00:00.000Z", snoozedAt: "2026-03-25T00:00:00.000Z", pinnedAt: "2026-03-25T00:00:00.000Z", @@ -186,6 +188,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { ...row, settledOverride: "active", settledAt: null, + unsettledAt: "2026-03-26T00:00:00.000Z", snoozedUntil: null, snoozedAt: null, pinnedAt: null, @@ -196,9 +199,62 @@ projectionRepositoriesLayer("Projection repositories", (it) => { const updated = Option.getOrNull(repersisted); assert.strictEqual(updated?.settledOverride, "active"); assert.strictEqual(updated?.settledAt, null); + assert.strictEqual(updated?.unsettledAt, "2026-03-26T00:00:00.000Z"); assert.strictEqual(updated?.snoozedUntil, null); assert.strictEqual(updated?.snoozedAt, null); assert.strictEqual(updated?.pinnedAt, null); }), ); + + it.effect("round-trips a linked pull request through the thread row", () => + Effect.gen(function* () { + const threads = yield* ProjectionThreadRepository; + const linkedPullRequest = { + projectId: ProjectId.make("project-linked-pr"), + repository: "pingdotgg/t3code", + number: 42, + url: "https://github.com/pingdotgg/t3code/pull/42", + }; + + yield* threads.upsert({ + threadId: ThreadId.make("thread-linked-pr"), + projectId: ProjectId.make("project-linked-pr"), + title: "Linked pull request", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.4", + }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + linkedPullRequest, + latestTurnId: null, + createdAt: "2026-03-24T00:00:00.000Z", + updatedAt: "2026-03-24T00:00:00.000Z", + archivedAt: null, + settledOverride: null, + settledAt: null, + unsettledAt: null, + snoozedUntil: null, + snoozedAt: null, + pinnedAt: null, + latestUserMessageAt: null, + pendingApprovalCount: 0, + pendingUserInputCount: 0, + hasActionableProposedPlan: 0, + deletedAt: null, + }); + + const persisted = yield* threads.getById({ threadId: ThreadId.make("thread-linked-pr") }); + assert.deepStrictEqual(Option.getOrNull(persisted)?.linkedPullRequest, linkedPullRequest); + + const row = Option.getOrNull(persisted); + if (row === null) return yield* Effect.die("Expected linked thread row to exist."); + yield* threads.upsert({ ...row, linkedPullRequest: null }); + + const cleared = yield* threads.getById({ threadId: ThreadId.make("thread-linked-pr") }); + assert.strictEqual(Option.getOrNull(cleared)?.linkedPullRequest, null); + }), + ); }); diff --git a/apps/server/src/persistence/Layers/ProjectionThreadActivities.ts b/apps/server/src/persistence/Layers/ProjectionThreadActivities.ts index 2f4815f96545..fa3c948e4f3d 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadActivities.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadActivities.ts @@ -23,6 +23,21 @@ const ProjectionThreadActivityDbRowSchema = ProjectionThreadActivity.mapFields( }), ); +const mapActivityRows = ( + rows: ReadonlyArray>, +): ReadonlyArray => + rows.map((row) => ({ + activityId: row.activityId, + threadId: row.threadId, + turnId: row.turnId, + tone: row.tone, + kind: row.kind, + summary: row.summary, + payload: row.payload, + ...(row.sequence !== null ? { sequence: row.sequence } : {}), + createdAt: row.createdAt, + })); + function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) { return (cause: unknown) => Schema.isSchemaError(cause) @@ -97,6 +112,36 @@ const makeProjectionThreadActivityRepository = Effect.gen(function* () { `, }); + const listUserInputLifecycleActivityRows = SqlSchema.findAll({ + Request: ListProjectionThreadActivitiesInput, + Result: ProjectionThreadActivityDbRowSchema, + execute: ({ threadId }) => + sql` + SELECT + activity_id AS "activityId", + thread_id AS "threadId", + turn_id AS "turnId", + tone, + kind, + summary, + payload_json AS "payload", + sequence, + created_at AS "createdAt" + FROM projection_thread_activities + WHERE thread_id = ${threadId} + AND kind IN ( + 'user-input.requested', + 'user-input.resolved', + 'provider.user-input.respond.failed' + ) + ORDER BY + CASE WHEN sequence IS NULL THEN 0 ELSE 1 END ASC, + sequence ASC, + created_at ASC, + activity_id ASC + `, + }); + const deleteProjectionThreadActivityRows = SqlSchema.void({ Request: DeleteProjectionThreadActivitiesInput, execute: ({ threadId }) => @@ -124,21 +169,21 @@ const makeProjectionThreadActivityRepository = Effect.gen(function* () { "ProjectionThreadActivityRepository.listByThreadId:decodeRows", ), ), - Effect.map((rows) => - rows.map((row) => ({ - activityId: row.activityId, - threadId: row.threadId, - turnId: row.turnId, - tone: row.tone, - kind: row.kind, - summary: row.summary, - payload: row.payload, - ...(row.sequence !== null ? { sequence: row.sequence } : {}), - createdAt: row.createdAt, - })), - ), + Effect.map(mapActivityRows), ); + const listUserInputLifecycleByThreadId: ProjectionThreadActivityRepositoryShape["listUserInputLifecycleByThreadId"] = + (input) => + listUserInputLifecycleActivityRows(input).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionThreadActivityRepository.listUserInputLifecycleByThreadId:query", + "ProjectionThreadActivityRepository.listUserInputLifecycleByThreadId:decodeRows", + ), + ), + Effect.map(mapActivityRows), + ); + const deleteByThreadId: ProjectionThreadActivityRepositoryShape["deleteByThreadId"] = (input) => deleteProjectionThreadActivityRows(input).pipe( Effect.mapError( @@ -149,6 +194,7 @@ const makeProjectionThreadActivityRepository = Effect.gen(function* () { return { upsert, listByThreadId, + listUserInputLifecycleByThreadId, deleteByThreadId, } satisfies ProjectionThreadActivityRepositoryShape; }); diff --git a/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts b/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts index b1f394a9e577..30e0f42cab89 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts @@ -12,6 +12,71 @@ const layer = it.layer( ); layer("ProjectionThreadMessageRepository", (it) => { + it.effect("appends streaming text and applies attachment updates", () => + Effect.gen(function* () { + const repository = yield* ProjectionThreadMessageRepository; + const threadId = ThreadId.make("thread-streaming-append"); + const messageId = MessageId.make("message-streaming-append"); + const createdAt = "2026-02-28T19:05:00.000Z"; + const attachments = [ + { + type: "image" as const, + id: "thread-streaming-append-att-1", + name: "example.png", + mimeType: "image/png", + sizeBytes: 5, + }, + ]; + + yield* repository.appendStreaming({ + messageId, + threadId, + turnId: null, + role: "assistant", + text: "hello", + attachments, + createdAt, + updatedAt: createdAt, + }); + yield* repository.appendStreaming({ + messageId, + threadId, + turnId: null, + role: "assistant", + text: " world", + createdAt: "2026-02-28T19:05:01.000Z", + updatedAt: "2026-02-28T19:05:01.000Z", + }); + + const rowWithPreservedAttachments = yield* repository.getByMessageId({ messageId }); + assert.equal(rowWithPreservedAttachments._tag, "Some"); + if (rowWithPreservedAttachments._tag === "Some") { + assert.deepEqual(rowWithPreservedAttachments.value.attachments, attachments); + } + + yield* repository.appendStreaming({ + messageId, + threadId, + turnId: null, + role: "assistant", + text: "", + attachments: [], + createdAt: "2026-02-28T19:05:02.000Z", + updatedAt: "2026-02-28T19:05:02.000Z", + }); + + const row = yield* repository.getByMessageId({ messageId }); + assert.equal(row._tag, "Some"); + if (row._tag === "Some") { + assert.equal(row.value.text, "hello world"); + assert.deepEqual(row.value.attachments, []); + assert.equal(row.value.createdAt, createdAt); + assert.equal(row.value.updatedAt, "2026-02-28T19:05:02.000Z"); + assert.isTrue(row.value.isStreaming); + } + }), + ); + it.effect("preserves existing attachments when upsert omits attachments", () => Effect.gen(function* () { const repository = yield* ProjectionThreadMessageRepository; diff --git a/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts b/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts index 719191668869..85e854dc6606 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts @@ -9,6 +9,7 @@ import { ChatAttachment } from "@t3tools/contracts"; import { toPersistenceSqlError } from "../Errors.ts"; import { + AppendStreamingProjectionThreadMessage, GetProjectionThreadMessageInput, ProjectionThreadMessageRepository, type ProjectionThreadMessageRepositoryShape, @@ -95,6 +96,50 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { }, }); + const appendStreamingProjectionThreadMessageRow = SqlSchema.void({ + Request: AppendStreamingProjectionThreadMessage, + execute: (row) => { + const nextAttachmentsJson = + row.attachments !== undefined ? JSON.stringify(row.attachments) : null; + return sql` + INSERT INTO projection_thread_messages ( + message_id, + thread_id, + turn_id, + role, + text, + attachments_json, + is_streaming, + created_at, + updated_at + ) + VALUES ( + ${row.messageId}, + ${row.threadId}, + ${row.turnId}, + ${row.role}, + ${row.text}, + ${nextAttachmentsJson}, + 1, + ${row.createdAt}, + ${row.updatedAt} + ) + ON CONFLICT (message_id) + DO UPDATE SET + thread_id = excluded.thread_id, + turn_id = excluded.turn_id, + role = excluded.role, + text = projection_thread_messages.text || excluded.text, + attachments_json = COALESCE( + excluded.attachments_json, + projection_thread_messages.attachments_json + ), + is_streaming = 1, + updated_at = excluded.updated_at + `; + }, + }); + const getProjectionThreadMessageRow = SqlSchema.findOneOption({ Request: GetProjectionThreadMessageInput, Result: ProjectionThreadMessageDbRowSchema, @@ -151,6 +196,13 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { Effect.mapError(toPersistenceSqlError("ProjectionThreadMessageRepository.upsert:query")), ); + const appendStreaming: ProjectionThreadMessageRepositoryShape["appendStreaming"] = (row) => + appendStreamingProjectionThreadMessageRow(row).pipe( + Effect.mapError( + toPersistenceSqlError("ProjectionThreadMessageRepository.appendStreaming:query"), + ), + ); + const getByMessageId: ProjectionThreadMessageRepositoryShape["getByMessageId"] = (input) => getProjectionThreadMessageRow(input).pipe( Effect.mapError( @@ -176,6 +228,7 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { return { upsert, + appendStreaming, getByMessageId, listByThreadId, deleteByThreadId, diff --git a/apps/server/src/persistence/Layers/ProjectionThreads.ts b/apps/server/src/persistence/Layers/ProjectionThreads.ts index b7d8ae137473..d5653a2c8b42 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreads.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreads.ts @@ -14,11 +14,12 @@ import { ProjectionThreadRepository, type ProjectionThreadRepositoryShape, } from "../Services/ProjectionThreads.ts"; -import { ModelSelection } from "@t3tools/contracts"; +import { ModelSelection, ThreadLinkedPullRequest } from "@t3tools/contracts"; const ProjectionThreadDbRow = ProjectionThread.mapFields( Struct.assign({ modelSelection: Schema.fromJsonString(ModelSelection), + linkedPullRequest: Schema.NullOr(Schema.fromJsonString(ThreadLinkedPullRequest)), }), ); type ProjectionThreadDbRow = typeof ProjectionThreadDbRow.Type; @@ -39,12 +40,14 @@ const makeProjectionThreadRepository = Effect.gen(function* () { interaction_mode, branch, worktree_path, + linked_pull_request_json, latest_turn_id, created_at, updated_at, archived_at, settled_override, settled_at, + unsettled_at, snoozed_until, snoozed_at, pinned_at, @@ -66,12 +69,14 @@ const makeProjectionThreadRepository = Effect.gen(function* () { ${row.interactionMode}, ${row.branch}, ${row.worktreePath}, + ${row.linkedPullRequest === undefined || row.linkedPullRequest === null ? null : JSON.stringify(row.linkedPullRequest)}, ${row.latestTurnId}, ${row.createdAt}, ${row.updatedAt}, ${row.archivedAt}, ${row.settledOverride}, ${row.settledAt}, + ${row.unsettledAt}, ${row.snoozedUntil}, ${row.snoozedAt}, ${row.pinnedAt}, @@ -93,12 +98,14 @@ const makeProjectionThreadRepository = Effect.gen(function* () { interaction_mode = excluded.interaction_mode, branch = excluded.branch, worktree_path = excluded.worktree_path, + linked_pull_request_json = excluded.linked_pull_request_json, latest_turn_id = excluded.latest_turn_id, created_at = excluded.created_at, updated_at = excluded.updated_at, archived_at = excluded.archived_at, settled_override = excluded.settled_override, settled_at = excluded.settled_at, + unsettled_at = excluded.unsettled_at, snoozed_until = excluded.snoozed_until, snoozed_at = excluded.snoozed_at, pinned_at = excluded.pinned_at, @@ -127,12 +134,14 @@ const makeProjectionThreadRepository = Effect.gen(function* () { interaction_mode AS "interactionMode", branch, worktree_path AS "worktreePath", + linked_pull_request_json AS "linkedPullRequest", latest_turn_id AS "latestTurnId", created_at AS "createdAt", updated_at AS "updatedAt", archived_at AS "archivedAt", settled_override AS "settledOverride", settled_at AS "settledAt", + unsettled_at AS "unsettledAt", snoozed_until AS "snoozedUntil", snoozed_at AS "snoozedAt", pinned_at AS "pinnedAt", @@ -163,12 +172,14 @@ const makeProjectionThreadRepository = Effect.gen(function* () { interaction_mode AS "interactionMode", branch, worktree_path AS "worktreePath", + linked_pull_request_json AS "linkedPullRequest", latest_turn_id AS "latestTurnId", created_at AS "createdAt", updated_at AS "updatedAt", archived_at AS "archivedAt", settled_override AS "settledOverride", settled_at AS "settledAt", + unsettled_at AS "unsettledAt", snoozed_until AS "snoozedUntil", snoozed_at AS "snoozedAt", pinned_at AS "pinnedAt", diff --git a/apps/server/src/persistence/Layers/ProviderSessionRuntime.ts b/apps/server/src/persistence/Layers/ProviderSessionRuntime.ts deleted file mode 100644 index 52e4f8f74088..000000000000 --- a/apps/server/src/persistence/Layers/ProviderSessionRuntime.ts +++ /dev/null @@ -1,2 +0,0 @@ -/** @deprecated Compatibility alias for the excluded orchestration integration harness. */ -export { layer as ProviderSessionRuntimeRepositoryLive } from "../ProviderSessionRuntime.ts"; diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 170cb3992279..84b0d52eea76 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -1,16 +1,15 @@ /** - * MigrationsLive - Migration runner with inline loader + * Migration runner with an inline loader. * * Uses Migrator.make with fromRecord to define migrations inline. * All migrations are statically imported - no dynamic file system loading. * - * Migrations run automatically when the MigrationLayer is provided, - * ensuring the database schema is always up-to-date before the application starts. + * `runMigrations` is called by the SQLite persistence layer at startup, so the + * schema is always up to date before the application starts. */ import * as Migrator from "effect/unstable/sql/Migrator"; import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; // Import all migrations statically import Migration0001 from "./Migrations/001_OrchestrationEvents.ts"; @@ -54,6 +53,8 @@ import Migration0038 from "./Migrations/038_ProjectionThreadsPinOrderKey.ts"; import Migration0039 from "./Migrations/039_ProjectionProjectsDefaultThreadEnvMode.ts"; import Migration0040 from "./Migrations/040_ProjectionProjectFaviconPath.ts"; import Migration0041 from "./Migrations/041_AuthSessionClientConnection.ts"; +import Migration0042 from "./Migrations/042_ProjectionThreadLinkedPullRequest.ts"; +import Migration0043 from "./Migrations/043_ProjectionThreadsUnsettledAt.ts"; /** * Migration loader with all migrations defined inline. @@ -107,6 +108,8 @@ export const migrationEntries = [ [39, "ProjectionProjectsDefaultThreadEnvMode", Migration0039], [40, "ProjectionProjectFaviconPath", Migration0040], [41, "AuthSessionClientConnection", Migration0041], + [42, "ProjectionThreadLinkedPullRequest", Migration0042], + [43, "ProjectionThreadsUnsettledAt", Migration0043], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); @@ -150,22 +153,3 @@ export const runMigrations = Effect.fn("runMigrations")(function* ({ : Effect.log("Migrations ran successfully").pipe(Effect.annotateLogs({ migrations })); return executedMigrations; }); - -/** - * Layer that runs migrations when the layer is built. - * - * Use this to ensure migrations run before your application starts. - * Migrations are run automatically - no separate script is needed. - * - * @example - * ```typescript - * import { MigrationsLive } from "@acme/db/Migrations" - * import * as SqliteClient from "@acme/db/SqliteClient" - * - * // Migrations run automatically when SqliteClient is provided - * const AppLayer = MigrationsLive.pipe( - * Layer.provideMerge(SqliteClient.layer({ filename: "database.sqlite" })) - * ) - * ``` - */ -export const MigrationsLive = Layer.effectDiscard(runMigrations()); diff --git a/apps/server/src/persistence/Migrations/042_ProjectionThreadLinkedPullRequest.test.ts b/apps/server/src/persistence/Migrations/042_ProjectionThreadLinkedPullRequest.test.ts new file mode 100644 index 000000000000..1fe59df50729 --- /dev/null +++ b/apps/server/src/persistence/Migrations/042_ProjectionThreadLinkedPullRequest.test.ts @@ -0,0 +1,25 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { runMigrations } from "../Migrations.ts"; +import * as NodeSqliteClient from "../NodeSqliteClient.ts"; + +const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); + +layer("042_ProjectionThreadLinkedPullRequest", (it) => { + it.effect("adds the linked pull request column", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* runMigrations({ toMigrationInclusive: 41 }); + yield* runMigrations({ toMigrationInclusive: 42 }); + + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_threads) + `; + assert.ok(columns.some((column) => column.name === "linked_pull_request_json")); + }), + ); +}); diff --git a/apps/server/src/persistence/Migrations/042_ProjectionThreadLinkedPullRequest.ts b/apps/server/src/persistence/Migrations/042_ProjectionThreadLinkedPullRequest.ts new file mode 100644 index 000000000000..a026f39c392a --- /dev/null +++ b/apps/server/src/persistence/Migrations/042_ProjectionThreadLinkedPullRequest.ts @@ -0,0 +1,16 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_threads) + `; + + if (!columns.some((column) => column.name === "linked_pull_request_json")) { + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN linked_pull_request_json TEXT + `; + } +}); diff --git a/apps/server/src/persistence/Migrations/043_ProjectionThreadsUnsettledAt.ts b/apps/server/src/persistence/Migrations/043_ProjectionThreadsUnsettledAt.ts new file mode 100644 index 000000000000..981d3c78f3a6 --- /dev/null +++ b/apps/server/src/persistence/Migrations/043_ProjectionThreadsUnsettledAt.ts @@ -0,0 +1,16 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_threads) + `; + + if (!columns.some((column) => column.name === "unsettled_at")) { + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN unsettled_at TEXT + `; + } +}); diff --git a/apps/server/src/persistence/Services/OrchestrationEventStore.ts b/apps/server/src/persistence/Services/OrchestrationEventStore.ts index 8b465e7713e1..b865957c06b3 100644 --- a/apps/server/src/persistence/Services/OrchestrationEventStore.ts +++ b/apps/server/src/persistence/Services/OrchestrationEventStore.ts @@ -52,6 +52,20 @@ export interface OrchestrationEventStoreShape { * @returns Stream containing all stored events. */ readonly readAll: () => Stream.Stream; + + /** + * Check whether an aggregate has an event after a sequence, optionally + * restricted to one event type. + * + * Used during replay to tell whether a later event supersedes the one being + * applied, without streaming the rest of the log. + */ + readonly hasEventAfter: (input: { + readonly aggregateKind: OrchestrationEvent["aggregateKind"]; + readonly aggregateId: string; + readonly type?: OrchestrationEvent["type"]; + readonly sequenceExclusive: number; + }) => Effect.Effect; } /** diff --git a/apps/server/src/persistence/Services/ProjectionPendingApprovals.ts b/apps/server/src/persistence/Services/ProjectionPendingApprovals.ts index 967e6da9d3af..40b0d1ae03b6 100644 --- a/apps/server/src/persistence/Services/ProjectionPendingApprovals.ts +++ b/apps/server/src/persistence/Services/ProjectionPendingApprovals.ts @@ -82,6 +82,13 @@ export interface ProjectionPendingApprovalRepositoryShape { readonly deleteByRequestId: ( input: DeleteProjectionPendingApprovalInput, ) => Effect.Effect; + + /** + * Delete every pending approval row for a thread. + */ + readonly deleteByThreadId: ( + input: ListProjectionPendingApprovalsInput, + ) => Effect.Effect; } /** diff --git a/apps/server/src/persistence/Services/ProjectionThreadActivities.ts b/apps/server/src/persistence/Services/ProjectionThreadActivities.ts index 47cb6073c479..e8c1e47a328b 100644 --- a/apps/server/src/persistence/Services/ProjectionThreadActivities.ts +++ b/apps/server/src/persistence/Services/ProjectionThreadActivities.ts @@ -67,6 +67,15 @@ export interface ProjectionThreadActivityRepositoryShape { input: ListProjectionThreadActivitiesInput, ) => Effect.Effect, ProjectionRepositoryError>; + /** + * List activity rows used to derive pending user-input state. + * + * Filters in SQLite so unrelated payloads do not enter server memory. + */ + readonly listUserInputLifecycleByThreadId: ( + input: ListProjectionThreadActivitiesInput, + ) => Effect.Effect, ProjectionRepositoryError>; + /** * Delete projected thread activity rows by thread. */ diff --git a/apps/server/src/persistence/Services/ProjectionThreadMessages.ts b/apps/server/src/persistence/Services/ProjectionThreadMessages.ts index d50ff3202563..17b659a2f8da 100644 --- a/apps/server/src/persistence/Services/ProjectionThreadMessages.ts +++ b/apps/server/src/persistence/Services/ProjectionThreadMessages.ts @@ -16,6 +16,7 @@ import { } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; import * as Context from "effect/Context"; +import * as Struct from "effect/Struct"; import type * as Option from "effect/Option"; import type * as Effect from "effect/Effect"; @@ -34,6 +35,12 @@ export const ProjectionThreadMessage = Schema.Struct({ }); export type ProjectionThreadMessage = typeof ProjectionThreadMessage.Type; +export const AppendStreamingProjectionThreadMessage = Schema.Struct( + Struct.omit(ProjectionThreadMessage.fields, ["isStreaming"]), +); +export type AppendStreamingProjectionThreadMessage = + typeof AppendStreamingProjectionThreadMessage.Type; + export const ListProjectionThreadMessagesInput = Schema.Struct({ threadId: ThreadId, }); @@ -62,6 +69,11 @@ export interface ProjectionThreadMessageRepositoryShape { message: ProjectionThreadMessage, ) => Effect.Effect; + /** Insert a streaming message or append text to its existing row. */ + readonly appendStreaming: ( + message: AppendStreamingProjectionThreadMessage, + ) => Effect.Effect; + /** * Read a projected thread message by id. */ diff --git a/apps/server/src/persistence/Services/ProjectionThreads.ts b/apps/server/src/persistence/Services/ProjectionThreads.ts index c572e1d11ccd..a70548bc110c 100644 --- a/apps/server/src/persistence/Services/ProjectionThreads.ts +++ b/apps/server/src/persistence/Services/ProjectionThreads.ts @@ -14,6 +14,7 @@ import { ProjectId, ProviderInteractionMode, RuntimeMode, + ThreadLinkedPullRequest, ThreadId, TurnId, } from "@t3tools/contracts"; @@ -33,12 +34,14 @@ export const ProjectionThread = Schema.Struct({ interactionMode: ProviderInteractionMode, branch: Schema.NullOr(Schema.String), worktreePath: Schema.NullOr(Schema.String), + linkedPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)), latestTurnId: Schema.NullOr(TurnId), createdAt: IsoDateTime, updatedAt: IsoDateTime, archivedAt: Schema.NullOr(IsoDateTime), settledOverride: Schema.NullOr(Schema.Literals(["settled", "active"])), settledAt: Schema.NullOr(IsoDateTime), + unsettledAt: Schema.NullOr(IsoDateTime), snoozedUntil: Schema.NullOr(IsoDateTime), snoozedAt: Schema.NullOr(IsoDateTime), pinnedAt: Schema.NullOr(IsoDateTime), diff --git a/apps/server/src/process/externalLauncher.test.ts b/apps/server/src/process/externalLauncher.test.ts index 1ab6166e92a1..a72b42b60b75 100644 --- a/apps/server/src/process/externalLauncher.test.ts +++ b/apps/server/src/process/externalLauncher.test.ts @@ -1,3 +1,8 @@ +// @effect-diagnostics nodeBuiltinImport:off - the Windows reveal smoke test drives a real PowerShell through Node process and filesystem APIs. +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import * as ConfigProvider from "effect/ConfigProvider"; @@ -15,18 +20,30 @@ import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { SpawnExecutableResolution } from "@t3tools/shared/shell"; import * as ExternalLauncher from "./externalLauncher.ts"; -function makeMockDetachedHandle(onUnref: () => void = () => undefined) { +interface MockSpawnResult { + readonly exitCode?: number; + readonly stdout?: string; + /** Never deliver an exit code, like a child wedged on a broken desktop session. */ + readonly stall?: boolean; +} + +function makeMockDetachedHandle(input: MockSpawnResult & { readonly onUnref?: () => void } = {}) { return ChildProcessSpawner.makeHandle({ pid: ChildProcessSpawner.ProcessId(1), - exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)), + exitCode: input.stall + ? Effect.never + : Effect.succeed(ChildProcessSpawner.ExitCode(input.exitCode ?? 0)), isRunning: Effect.succeed(true), kill: () => Effect.void, unref: Effect.sync(() => { - onUnref(); + input.onUnref?.(); return Effect.void; }), stdin: Sink.drain, - stdout: Stream.empty, + stdout: + input.stdout === undefined + ? Stream.empty + : Stream.make(new TextEncoder().encode(input.stdout)), stderr: Stream.empty, all: Stream.empty, getInputFd: () => Sink.drain, @@ -40,6 +57,7 @@ const testLayer = (input: { readonly resolveExecutable?: (command: string) => string | undefined; readonly onSpawn?: (command: ChildProcess.StandardCommand) => void; readonly onUnref?: () => void; + readonly spawnResult?: (command: ChildProcess.StandardCommand) => MockSpawnResult | undefined; }) => { const spawnerLayer = Layer.succeed( ChildProcessSpawner.ChildProcessSpawner, @@ -50,7 +68,10 @@ const testLayer = (input: { throw new Error("Expected a standard command"); } input.onSpawn?.(command); - return makeMockDetachedHandle(input.onUnref); + return makeMockDetachedHandle({ + ...(input.onUnref === undefined ? {} : { onUnref: input.onUnref }), + ...input.spawnResult?.(command), + }); }), ), ); @@ -132,6 +153,623 @@ it.effect("launches an installed editor with platform-safe arguments", () => }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); +it.effect("reveals a file in Finder with open -R on macOS", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); + const openPath = path.join(binDir, "open"); + yield* fileSystem.writeFileString(openPath, "#!/bin/sh\n"); + yield* fileSystem.chmod(openPath, 0o755); + + let spawned: ChildProcess.StandardCommand | undefined; + yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + yield* launcher.launchEditor({ + editor: "file-manager", + cwd: "/workspace/media/linux-mini-v2.mp4", + reveal: true, + }); + }).pipe( + Effect.provide( + testLayer({ + platform: "darwin", + env: { PATH: binDir }, + onSpawn: (command) => { + spawned = command; + }, + }), + ), + ); + + assert.ok(spawned); + assert.equal(spawned.command, "open"); + assert.deepEqual(spawned.args, ["-R", "/workspace/media/linux-mini-v2.mp4"]); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("reveals a file in File Explorer through PowerShell on Windows", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); + yield* fileSystem.writeFileString(path.join(binDir, "explorer.CMD"), "@echo off\r\n"); + // resolvePowerShellPath builds `${SYSTEMROOT}\System32\...` with Windows + // separators, which on the posix test filesystem is one file name. + const systemRoot = path.join(binDir, "system-root"); + const powerShellPath = `${systemRoot}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe`; + yield* fileSystem.makeDirectory(path.dirname(powerShellPath), { recursive: true }); + yield* fileSystem.writeFileString(powerShellPath, ""); + + let spawned: ChildProcess.StandardCommand | undefined; + const kind = yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + yield* launcher.launchEditor({ + editor: "file-manager", + cwd: "C:\\workspace with spaces\\media\\author's clip.mp4", + reveal: true, + }); + return yield* launcher.resolveFileManagerRevealKind(); + }).pipe( + Effect.provide( + testLayer({ + platform: "win32", + env: { PATH: binDir, PATHEXT: ".COM;.EXE;.BAT;.CMD", SYSTEMROOT: systemRoot }, + onSpawn: (command) => { + spawned = command; + }, + }), + ), + ); + + assert.equal(kind, "file-explorer"); + assert.ok(spawned); + assert.equal(spawned.command, powerShellPath); + assert.deepEqual(spawned.args.slice(0, -1), [ + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-EncodedCommand", + ]); + const encodedCommand = spawned.args[spawned.args.length - 1] ?? ""; + const decodedCommand = Buffer.from(encodedCommand, "base64").toString("utf16le"); + // explorer.exe expects `/select,""` with only the path quoted; + // PowerShell 5.1's Start-Process passes the argument string verbatim. + assert.equal( + decodedCommand, + "$ProgressPreference = 'SilentlyContinue'; Start-Process 'explorer.exe' -ArgumentList ('/select,\"' + 'C:\\workspace with spaces\\media\\author''s clip.mp4' + '\"')", + ); + assert.equal(spawned.options.shell, false); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +// Real-chain smoke check for the Explorer selection contract: runs the exact +// PowerShell source the reveal launch encodes, against a stub that records +// the raw argument tail it receives, and asserts a spaced path arrives as the +// single `/select,""` switch. Mock argv assertions cannot prove this — +// only Windows' own PowerShell -> CreateProcess quoting chain can, so the +// test runs only where that chain exists. +// oxlint-disable-next-line t3code/no-global-process-runtime -- the skip decision needs the real host platform, outside any Effect runtime. +it.skipIf(process.platform !== "win32")( + "delivers the raw /select switch for spaced paths through real PowerShell", + { timeout: 60_000 }, + async () => { + const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-reveal-smoke-")); + try { + const recorderPath = NodePath.join(tempDir, "recorder.cmd"); + const outputPath = NodePath.join(tempDir, "argv.txt"); + NodeFS.writeFileSync(recorderPath, `@echo off\r\n>"${outputPath}" echo(%*\r\n`); + + const target = "C:\\workspace with spaces\\media\\author's clip.mp4"; + const source = ExternalLauncher.buildFileExplorerRevealPowerShellSource(recorderPath, target); + const powerShellPath = `${process.env.SYSTEMROOT ?? "C:\\Windows"}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe`; + NodeChildProcess.execFileSync( + powerShellPath, + [ + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-EncodedCommand", + Buffer.from(source, "utf16le").toString("base64"), + ], + { timeout: 30_000 }, + ); + + // Start-Process returns before the recorder runs; wait for its output. + // The waits run outside the Effect runtime on purpose: the test + // exercises the real Windows process chain in real time. + // @effect-diagnostics-next-line globalTimers:off + const sleep = (millis: number) => new Promise((resolve) => setTimeout(resolve, millis)); + // @effect-diagnostics-next-line globalDate:off + const deadline = Date.now() + 20_000; + // @effect-diagnostics-next-line globalDate:off + while (!NodeFS.existsSync(outputPath) && Date.now() < deadline) { + await sleep(100); + } + await sleep(200); + const recorded = NodeFS.readFileSync(outputPath, "utf8").trim(); + assert.equal(recorded, `/select,"${target}"`); + } finally { + NodeFS.rmSync(tempDir, { recursive: true, force: true }); + } + }, +); + +it.effect("does not advertise reveal on Windows when PowerShell is missing", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); + yield* fileSystem.writeFileString(path.join(binDir, "explorer.CMD"), "@echo off\r\n"); + + const result = yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + return { + kind: yield* launcher.resolveFileManagerRevealKind(), + editors: yield* launcher.resolveAvailableEditors(), + }; + }).pipe( + Effect.provide( + testLayer({ + platform: "win32", + env: { + PATH: binDir, + PATHEXT: ".COM;.EXE;.BAT;.CMD", + SYSTEMROOT: path.join(binDir, "missing-system-root"), + }, + }), + ), + ); + + // Plain "open in file manager" still works through explorer; only the + // reveal capability, which launches PowerShell, must stay hidden. + assert.equal(result.editors.includes("file-manager"), true); + assert.isUndefined(result.kind); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("reveals a WSL file in Windows File Explorer through its UNC path", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); + for (const name of ["explorer.exe", "powershell.exe", "xdg-open"]) { + const filePath = path.join(binDir, name); + yield* fileSystem.writeFileString(filePath, "#!/bin/sh\n"); + yield* fileSystem.chmod(filePath, 0o755); + } + + let spawned: ChildProcess.StandardCommand | undefined; + const result = yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + const kind = yield* launcher.resolveFileManagerRevealKind(); + const editors = yield* launcher.resolveAvailableEditors(); + yield* launcher.launchEditor({ + editor: "file-manager", + cwd: "/home/t3/workspace/media/clip.mp4", + reveal: true, + }); + return { kind, editors }; + }).pipe( + Effect.provide( + testLayer({ + platform: "linux", + env: { + PATH: binDir, + WSL_DISTRO_NAME: "Ubuntu-24.04", + WSL_INTEROP: "/run/WSL/1_interop", + }, + onSpawn: (command) => { + spawned = command; + }, + }), + ), + ); + + assert.equal(result.kind, "file-explorer"); + assert.equal(result.editors.includes("file-manager"), true); + assert.ok(spawned); + // The reveal routes through interop PowerShell so Explorer receives its + // raw `/select,""` switch even for spaced paths. + assert.equal(spawned.command, "powershell.exe"); + const encodedCommand = spawned.args[spawned.args.length - 1] ?? ""; + const decodedCommand = Buffer.from(encodedCommand, "base64").toString("utf16le"); + assert.equal( + decodedCommand, + "$ProgressPreference = 'SilentlyContinue'; Start-Process 'explorer.exe' -ArgumentList ('/select,\"' + '\\\\wsl.localhost\\Ubuntu-24.04\\home\\t3\\workspace\\media\\clip.mp4' + '\"')", + ); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("does not advertise reveal from WSL when interop PowerShell is missing", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); + const explorerPath = path.join(binDir, "explorer.exe"); + yield* fileSystem.writeFileString(explorerPath, ""); + yield* fileSystem.chmod(explorerPath, 0o755); + + const result = yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + return { + kind: yield* launcher.resolveFileManagerRevealKind(), + editors: yield* launcher.resolveAvailableEditors(), + }; + }).pipe( + Effect.provide( + testLayer({ + platform: "linux", + env: { + PATH: binDir, + WSL_DISTRO_NAME: "Ubuntu-24.04", + WSL_INTEROP: "/run/WSL/1_interop", + }, + }), + ), + ); + + assert.equal(result.editors.includes("file-manager"), true); + assert.isUndefined(result.kind); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +// When interop PowerShell is missing the capability advertises the Linux +// "files" kind (or nothing), so the reveal must open the Linux file manager +// the label promised even though plain open still prefers File Explorer. +it.effect("reveals through the Linux file manager when WSL lacks interop PowerShell", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); + for (const name of ["explorer.exe", "xdg-open", "xdg-mime"]) { + const filePath = path.join(binDir, name); + yield* fileSystem.writeFileString(filePath, "#!/bin/sh\n"); + yield* fileSystem.chmod(filePath, 0o755); + } + + const spawnedCommands: ChildProcess.StandardCommand[] = []; + const kind = yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + const revealKind = yield* launcher.resolveFileManagerRevealKind(); + yield* launcher.launchEditor({ + editor: "file-manager", + cwd: "/home/t3/workspace/media/clip.mp4", + reveal: true, + }); + return revealKind; + }).pipe( + Effect.provide( + testLayer({ + platform: "linux", + env: { + PATH: binDir, + WSL_DISTRO_NAME: "Ubuntu-24.04", + WSL_INTEROP: "/run/WSL/1_interop", + DISPLAY: ":0", + }, + onSpawn: (command) => { + spawnedCommands.push(command); + }, + spawnResult: (command) => + command.command === "xdg-mime" ? { stdout: "org.gnome.Nautilus.desktop\n" } : undefined, + }), + ), + ); + + assert.equal(kind, "files"); + const launch = spawnedCommands.find((command) => command.command === "xdg-open"); + assert.ok(launch); + assert.deepEqual(launch.args, ["/home/t3/workspace/media"]); + assert.isUndefined(spawnedCommands.find((command) => command.command === "explorer.exe")); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +// Interop can exist without `explorer.exe` on PATH (appendWindowsPath=false) +// while WSLg still provides a working Linux file manager; the host must keep +// the Linux open/reveal path instead of losing the editor entirely. +it.effect("falls back to the Linux file manager when WSL lacks the Explorer bridge", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); + for (const name of ["xdg-open", "xdg-mime"]) { + const filePath = path.join(binDir, name); + yield* fileSystem.writeFileString(filePath, "#!/bin/sh\n"); + yield* fileSystem.chmod(filePath, 0o755); + } + + const spawnedCommands: ChildProcess.StandardCommand[] = []; + const result = yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + const editors = yield* launcher.resolveAvailableEditors(); + const kind = yield* launcher.resolveFileManagerRevealKind(); + yield* launcher.launchEditor({ + editor: "file-manager", + cwd: "/home/t3/workspace/media/clip.mp4", + reveal: true, + }); + return { editors, kind }; + }).pipe( + Effect.provide( + testLayer({ + platform: "linux", + env: { + PATH: binDir, + WSL_DISTRO_NAME: "Ubuntu-24.04", + WSL_INTEROP: "/run/WSL/1_interop", + DISPLAY: ":0", + }, + onSpawn: (command) => { + spawnedCommands.push(command); + }, + spawnResult: (command) => + command.command === "xdg-mime" ? { stdout: "org.gnome.Nautilus.desktop\n" } : undefined, + }), + ), + ); + + assert.equal(result.editors.includes("file-manager"), true); + assert.equal(result.kind, "files"); + const launch = spawnedCommands.find((command) => command.command === "xdg-open"); + assert.ok(launch); + assert.deepEqual(launch.args, ["/home/t3/workspace/media"]); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect( + "falls back to opening the containing directory for WSL paths Explorer cannot select", + () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); + for (const name of ["explorer.exe", "powershell.exe"]) { + const filePath = path.join(binDir, name); + yield* fileSystem.writeFileString(filePath, "#!/bin/sh\n"); + yield* fileSystem.chmod(filePath, 0o755); + } + + let spawned: ChildProcess.StandardCommand | undefined; + yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + yield* launcher.launchEditor({ + editor: "file-manager", + cwd: '/home/t3/work "quoted"/clip.mp4', + reveal: true, + }); + }).pipe( + Effect.provide( + testLayer({ + platform: "linux", + env: { + PATH: binDir, + WSL_DISTRO_NAME: "Ubuntu-24.04", + WSL_INTEROP: "/run/WSL/1_interop", + }, + onSpawn: (command) => { + spawned = command; + }, + }), + ), + ); + + // Explorer's raw switch cannot express a double quote, so the launch + // opens the parent directory instead of misparsing a /select argument. + assert.ok(spawned); + assert.equal(spawned.command, "explorer.exe"); + assert.deepEqual(spawned.args, ['\\\\wsl.localhost\\Ubuntu-24.04\\home\\t3\\work "quoted"']); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("reveals by opening the containing directory on Linux", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); + for (const name of ["xdg-open", "xdg-mime"]) { + const filePath = path.join(binDir, name); + yield* fileSystem.writeFileString(filePath, "#!/bin/sh\n"); + yield* fileSystem.chmod(filePath, 0o755); + } + + const spawnedCommands: ChildProcess.StandardCommand[] = []; + yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + yield* launcher.launchEditor({ + editor: "file-manager", + cwd: "/workspace/media/linux-mini-v2.mp4", + reveal: true, + }); + }).pipe( + Effect.provide( + testLayer({ + platform: "linux", + env: { PATH: binDir, DISPLAY: ":0" }, + onSpawn: (command) => { + spawnedCommands.push(command); + }, + spawnResult: (command) => + command.command === "xdg-mime" ? { stdout: "org.gnome.Nautilus.desktop\n" } : undefined, + }), + ), + ); + + const spawned = spawnedCommands.find((command) => command.command === "xdg-open"); + assert.ok(spawned); + assert.deepEqual(spawned.args, ["/workspace/media"]); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("does not advertise a Linux file manager without a graphical session", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); + const xdgOpenPath = path.join(binDir, "xdg-open"); + yield* fileSystem.writeFileString(xdgOpenPath, "#!/bin/sh\n"); + yield* fileSystem.chmod(xdgOpenPath, 0o755); + + const editors = yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + return yield* launcher.resolveAvailableEditors(); + }).pipe(Effect.provide(testLayer({ platform: "linux", env: { PATH: binDir } }))); + + assert.equal(editors.includes("file-manager"), false); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("advertises a Linux file manager when a directory handler is installed", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); + for (const name of ["xdg-open", "xdg-mime"]) { + const filePath = path.join(binDir, name); + yield* fileSystem.writeFileString(filePath, "#!/bin/sh\n"); + yield* fileSystem.chmod(filePath, 0o755); + } + + let probe: ChildProcess.StandardCommand | undefined; + const editors = yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + return yield* launcher.resolveAvailableEditors(); + }).pipe( + Effect.provide( + testLayer({ + platform: "linux", + env: { PATH: binDir, DISPLAY: ":0" }, + onSpawn: (command) => { + probe = command; + }, + spawnResult: (command) => + command.command === "xdg-mime" ? { stdout: "org.gnome.Nautilus.desktop\n" } : undefined, + }), + ), + ); + + assert.equal(editors.includes("file-manager"), true); + assert.ok(probe); + assert.equal(probe.command, "xdg-mime"); + assert.deepEqual(probe.args, ["query", "default", "inode/directory"]); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +// `xdg-open` with a display variable but no `inode/directory` handler exits +// nonzero after the launch has already detached: without this gate the server +// advertises a reveal that is a silent no-op. +it.effect("does not advertise a Linux file manager without a directory handler", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); + for (const name of ["xdg-open", "xdg-mime"]) { + const filePath = path.join(binDir, name); + yield* fileSystem.writeFileString(filePath, "#!/bin/sh\n"); + yield* fileSystem.chmod(filePath, 0o755); + } + + const editors = yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + return yield* launcher.resolveAvailableEditors(); + }).pipe( + Effect.provide( + testLayer({ + platform: "linux", + env: { PATH: binDir, DISPLAY: ":0" }, + spawnResult: (command) => (command.command === "xdg-mime" ? { stdout: "" } : undefined), + }), + ), + ); + + assert.equal(editors.includes("file-manager"), false); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("does not advertise a Linux file manager when the handler query fails", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); + for (const name of ["xdg-open", "xdg-mime"]) { + const filePath = path.join(binDir, name); + yield* fileSystem.writeFileString(filePath, "#!/bin/sh\n"); + yield* fileSystem.chmod(filePath, 0o755); + } + + const editors = yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + return yield* launcher.resolveAvailableEditors(); + }).pipe( + Effect.provide( + testLayer({ + platform: "linux", + env: { PATH: binDir, DISPLAY: ":0" }, + spawnResult: (command) => + command.command === "xdg-mime" + ? { exitCode: 47, stdout: "org.gnome.Nautilus.desktop\n" } + : undefined, + }), + ), + ); + + assert.equal(editors.includes("file-manager"), false); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +// The handler probe carries its own timeout because the editor scan's outer +// timeout in server.getConfig degrades to an EMPTY editor list: a wedged +// xdg-mime must cost only the file manager, never the other editors. Runs on +// the live clock so the probe's real timeout fires. +it.live("a stalled handler probe drops only the file manager", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); + for (const name of ["xdg-open", "xdg-mime", "code"]) { + const filePath = path.join(binDir, name); + yield* fileSystem.writeFileString(filePath, "#!/bin/sh\n"); + yield* fileSystem.chmod(filePath, 0o755); + } + + const editors = yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + return yield* launcher.resolveAvailableEditors(); + }).pipe( + Effect.provide( + testLayer({ + platform: "linux", + env: { PATH: binDir, DISPLAY: ":0" }, + spawnResult: (command) => (command.command === "xdg-mime" ? { stall: true } : undefined), + }), + ), + ); + + assert.equal(editors.includes("vscode"), true); + assert.equal(editors.includes("file-manager"), false); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("does not advertise a Linux file manager when xdg-mime is missing", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); + const xdgOpenPath = path.join(binDir, "xdg-open"); + yield* fileSystem.writeFileString(xdgOpenPath, "#!/bin/sh\n"); + yield* fileSystem.chmod(xdgOpenPath, 0o755); + + const editors = yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + return yield* launcher.resolveAvailableEditors(); + }).pipe(Effect.provide(testLayer({ platform: "linux", env: { PATH: binDir, DISPLAY: ":0" } }))); + + assert.equal(editors.includes("file-manager"), false); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + it.effect("discovers editors through the service API", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/process/externalLauncher.ts b/apps/server/src/process/externalLauncher.ts index 8ec928f26fc3..96e6470311f4 100644 --- a/apps/server/src/process/externalLauncher.ts +++ b/apps/server/src/process/externalLauncher.ts @@ -15,6 +15,7 @@ import { ExternalLauncherUnknownEditorError, ExternalLauncherUnsupportedEditorError, type EditorId, + type FileManagerRevealKind, type LaunchEditorInput, } from "@t3tools/contracts"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; @@ -29,6 +30,7 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Ref from "effect/Ref"; +import * as Stream from "effect/Stream"; import * as ChildProcess from "effect/unstable/process/ChildProcess"; import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; @@ -99,6 +101,8 @@ const BrowserLaunchEnvConfig = Config.all({ SSH_CONNECTION: Config.string("SSH_CONNECTION").pipe(Config.option), SSH_TTY: Config.string("SSH_TTY").pipe(Config.option), container: Config.string("container").pipe(Config.option), + DISPLAY: Config.string("DISPLAY").pipe(Config.option), + WAYLAND_DISPLAY: Config.string("WAYLAND_DISPLAY").pipe(Config.option), }).pipe(Config.map(compactEnv)); const CommandLookupEnvConfig = Config.all({ @@ -193,7 +197,13 @@ function resolveWslPowerShellPath(): string { return "/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe"; } -function shouldUseWindowsBrowserFromWsl( +// File reveals from WSL resolve PowerShell through the interop PATH rather +// than the fixed /mnt/c mount: the automount root is configurable, and a +// PATH-resolved command keeps the advertised capability aligned with the +// availability check `launchEditor` performs before spawning. +const WSL_POWERSHELL_COMMAND = "powershell.exe"; + +function shouldUseWindowsHostFromWsl( platform: NodeJS.Platform, env: NodeJS.ProcessEnv = {}, ): boolean { @@ -223,17 +233,163 @@ function resolveWindowsBrowserLaunch(target: string, command: string): ProcessLa }; } -function fileManagerCommandForPlatform(platform: NodeJS.Platform): string { +function hasGraphicalLinuxSession(env: NodeJS.ProcessEnv): boolean { + return [env.DISPLAY, env.WAYLAND_DISPLAY].some( + (value) => value !== undefined && value.trim().length > 0, + ); +} + +function fileManagerCommandForPlatform( + platform: NodeJS.Platform, + env: NodeJS.ProcessEnv, +): string | undefined { switch (platform) { case "darwin": return "open"; case "win32": return "explorer"; default: - return "xdg-open"; + if (shouldUseWindowsHostFromWsl(platform, env)) { + return env.WSL_DISTRO_NAME?.trim() ? "explorer.exe" : undefined; + } + return hasGraphicalLinuxSession(env) ? "xdg-open" : undefined; } } +// A graphical session variable plus an executable `xdg-open` does not prove +// that opening a directory does anything: without an `inode/directory` MIME +// handler, `xdg-open` exits nonzero after the launcher has already detached, +// so the client would see a silent no-op. Require the handler before +// advertising the file manager on Linux. +// +// The probe carries its own timeout well inside the scan timeout +// `server.getConfig` applies to editor discovery: that outer timeout degrades +// to an empty editor list, so a hung `xdg-mime` (broken D-Bus or desktop +// session) must cost only the file manager, not every discovered editor. +const LINUX_DIRECTORY_HANDLER_PROBE_TIMEOUT = "2 seconds"; + +const hasUsableLinuxDirectoryHandler = Effect.fn("externalLauncher.hasUsableLinuxDirectoryHandler")( + function* ( + env: NodeJS.ProcessEnv, + ): Effect.fn.Return< + boolean, + never, + FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner + > { + if (!(yield* isCommandAvailable("xdg-mime", { env }))) { + return false; + } + + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + return yield* spawner + .spawn( + ChildProcess.make("xdg-mime", ["query", "default", "inode/directory"], { + stdin: "ignore", + stderr: "ignore", + }), + ) + .pipe( + Effect.flatMap((handle) => + Effect.all([handle.stdout.pipe(Stream.decodeText(), Stream.mkString), handle.exitCode], { + concurrency: "unbounded", + }), + ), + Effect.map(([stdout, exitCode]) => exitCode === 0 && stdout.trim().length > 0), + Effect.scoped, + Effect.timeout(LINUX_DIRECTORY_HANDLER_PROBE_TIMEOUT), + Effect.orElseSucceed(() => false), + ); + }, +); + +const isUsableFileManagerCommand = Effect.fn("externalLauncher.isUsableFileManagerCommand")( + function* ( + command: string, + env: NodeJS.ProcessEnv, + ): Effect.fn.Return< + boolean, + never, + FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner + > { + if (!(yield* isCommandAvailable(command, { env }))) { + return false; + } + return command !== "xdg-open" || (yield* hasUsableLinuxDirectoryHandler(env)); + }, +); + +// The file-manager command a launch can actually run, not just the platform +// preference. WSL hosts prefer the Windows Explorer bridge, but interop can +// exist without `explorer.exe` on PATH (appendWindowsPath=false) or without a +// distro name while WSLg still provides a working Linux file manager, so they +// keep the `xdg-open` fallback instead of losing the editor entirely. +const resolveUsableFileManagerCommand = Effect.fn( + "externalLauncher.resolveUsableFileManagerCommand", +)(function* ( + platform: NodeJS.Platform, + env: NodeJS.ProcessEnv, +): Effect.fn.Return< + string | undefined, + never, + FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner +> { + const command = fileManagerCommandForPlatform(platform, env); + if (command !== undefined && (yield* isUsableFileManagerCommand(command, env))) { + return command; + } + if ( + shouldUseWindowsHostFromWsl(platform, env) && + hasGraphicalLinuxSession(env) && + (yield* isUsableFileManagerCommand("xdg-open", env)) + ) { + return "xdg-open"; + } + return undefined; +}); + +// Reveal on Windows and WSL runs through PowerShell (see +// resolveFileManagerRevealLaunch), not the `explorer` command that gates the +// file-manager editor itself, so the capability must probe the executables the +// reveal actually spawns. Callers gate on file-manager availability first; +// the Linux "files" kind relies on that gate for the directory-handler probe, +// while the WSL fallback re-probes because its availability may have come +// from the Explorer bridge instead. +const fileManagerRevealKindForPlatform = Effect.fn( + "externalLauncher.fileManagerRevealKindForPlatform", +)(function* ( + platform: NodeJS.Platform, + env: NodeJS.ProcessEnv, +): Effect.fn.Return< + FileManagerRevealKind | undefined, + never, + FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner +> { + if (platform === "darwin") return "finder"; + if (platform === "win32") { + return (yield* isCommandAvailable(resolvePowerShellPath(env), { env })) + ? "file-explorer" + : undefined; + } + if (shouldUseWindowsHostFromWsl(platform, env)) { + if ( + env.WSL_DISTRO_NAME?.trim() && + (yield* isCommandAvailable("explorer.exe", { env })) && + (yield* isCommandAvailable(WSL_POWERSHELL_COMMAND, { env })) + ) { + return "file-explorer"; + } + return hasGraphicalLinuxSession(env) && (yield* isUsableFileManagerCommand("xdg-open", env)) + ? "files" + : undefined; + } + return hasGraphicalLinuxSession(env) ? "files" : undefined; +}); + +function resolveWslFileManagerPath(target: string, distroName: string): string { + const relativePath = target.replace(/^\/+/, "").replaceAll("/", "\\"); + return `\\\\wsl.localhost\\${distroName}${relativePath.length > 0 ? `\\${relativePath}` : ""}`; +} + function buildBrowserLaunch( target: string, platform: NodeJS.Platform, @@ -251,7 +407,7 @@ function buildBrowserLaunch( return resolveWindowsBrowserLaunch(target, resolvePowerShellPath(env)); } - if (shouldUseWindowsBrowserFromWsl(platform, env)) { + if (shouldUseWindowsHostFromWsl(platform, env)) { return resolveWindowsBrowserLaunch(target, resolveWslPowerShellPath()); } @@ -265,13 +421,16 @@ function buildBrowserLaunch( const buildAvailableEditors = Effect.fn("externalLauncher.buildAvailableEditors")(function* ( platform: NodeJS.Platform, env: NodeJS.ProcessEnv, -): Effect.fn.Return, never, FileSystem.FileSystem | Path.Path> { +): Effect.fn.Return< + ReadonlyArray, + never, + FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner +> { const available: EditorId[] = []; for (const editor of EDITORS) { if (editor.commands === null) { - const command = fileManagerCommandForPlatform(platform); - if (yield* isCommandAvailable(command, { env })) { + if ((yield* resolveUsableFileManagerCommand(platform, env)) !== undefined) { available.push(editor.id); } continue; @@ -296,10 +455,18 @@ const resolveBrowserLaunch = Effect.fn("externalLauncher.resolveBrowserLaunch")( const resolveAvailableEditors = Effect.fn("externalLauncher.resolveAvailableEditors")(function* () { const platform = yield* HostProcessPlatform; - const env = yield* readCommandLookupEnv; + const env = { ...(yield* readBrowserLaunchEnv), ...(yield* readCommandLookupEnv) }; return yield* buildAvailableEditors(platform, env); }); +const resolveFileManagerRevealKind = Effect.fn("externalLauncher.resolveFileManagerRevealKind")( + function* () { + const platform = yield* HostProcessPlatform; + const env = { ...(yield* readBrowserLaunchEnv), ...(yield* readCommandLookupEnv) }; + return yield* fileManagerRevealKindForPlatform(platform, env); + }, +); + // Editor discovery walks PATH for every known editor and runs for every // client connect (the server config embeds the available editors). Memoize // the discovered set for a bounded window so repeat connects skip even the @@ -329,6 +496,14 @@ export class ExternalLauncher extends Context.Service< ExternalLauncher, { readonly resolveAvailableEditors: () => Effect.Effect>; + /** + * Reveal kind for the host, or undefined when the executable a reveal + * actually spawns is unavailable. Only meaningful when + * `resolveAvailableEditors` includes "file-manager": on Linux that + * availability check also carries the directory-handler probe this + * capability relies on. + */ + readonly resolveFileManagerRevealKind: () => Effect.Effect; /** Launch a URL target in the default browser. */ readonly launchBrowser: (target: string) => Effect.Effect; /** @@ -346,9 +521,13 @@ export class ExternalLauncher extends Context.Service< const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* ( input: LaunchEditorInput, -): Effect.fn.Return { +): Effect.fn.Return< + EditorLaunch, + ExternalLauncherError, + FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner +> { const platform = yield* HostProcessPlatform; - const env = yield* readCommandLookupEnv; + const env = { ...(yield* readBrowserLaunchEnv), ...(yield* readCommandLookupEnv) }; yield* Effect.annotateCurrentSpan({ "externalLauncher.editor": input.editor, "externalLauncher.cwd": input.cwd, @@ -376,14 +555,126 @@ const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* ( return yield* new ExternalLauncherUnsupportedEditorError({ editor: input.editor }); } + const command = yield* resolveUsableFileManagerCommand(platform, env); + if (command === undefined) { + return yield* new ExternalLauncherUnsupportedEditorError({ editor: input.editor }); + } + + if (input.reveal === true) { + return yield* resolveFileManagerRevealLaunch(input.cwd, platform, env, command); + } + return { editor: editorDef.id, target: input.cwd, - command: fileManagerCommandForPlatform(platform), - args: [input.cwd], + command, + args: + command === "explorer.exe" && env.WSL_DISTRO_NAME !== undefined + ? [resolveWslFileManagerPath(input.cwd, env.WSL_DISTRO_NAME)] + : [input.cwd], }; }); +/** + * PowerShell source that launches File Explorer with its raw selection + * switch. Explorer's contract is the single argument `/select,""` with + * only the path quoted; Node's default spawn quoting wraps the whole argument + * when the path has spaces and Explorer misparses it, silently opening a + * fallback folder. A single `-ArgumentList` string in Windows PowerShell 5.1 + * reaches the child's command line verbatim, preserving the raw switch. + * + * Exported so the Windows smoke test can drive the identical source through a + * real PowerShell against a recording stub instead of Explorer. + */ +export function buildFileExplorerRevealPowerShellSource( + explorerCommand: string, + target: string, +): string { + return `$ProgressPreference = 'SilentlyContinue'; Start-Process ${escapePowerShellStringLiteral(explorerCommand)} -ArgumentList ('/select,"' + ${escapePowerShellStringLiteral(target)} + '"')`; +} + +function fileExplorerRevealLaunch( + target: string, + explorerTarget: string, + powershellCommand: string, +): EditorLaunch { + return { + editor: "file-manager", + target, + command: powershellCommand, + args: [ + ...POWERSHELL_ARGUMENTS_PREFIX, + encodeUtf16LeBase64(buildFileExplorerRevealPowerShellSource("explorer.exe", explorerTarget)), + ], + }; +} + +const resolveFileManagerRevealLaunch = Effect.fn("resolveFileManagerRevealLaunch")(function* ( + target: string, + platform: NodeJS.Platform, + env: NodeJS.ProcessEnv, + // The command resolveUsableFileManagerCommand picked; a WSL host that fell + // back to the Linux file manager must reveal through it as well. + command: string, +): Effect.fn.Return< + EditorLaunch, + never, + FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner +> { + if (platform === "darwin") { + return { editor: "file-manager", target, command: "open", args: ["-R", target] }; + } + + if (platform === "win32") { + return fileExplorerRevealLaunch(target, target, resolvePowerShellPath(env)); + } + + if ( + command === "explorer.exe" && + shouldUseWindowsHostFromWsl(platform, env) && + env.WSL_DISTRO_NAME !== undefined + ) { + const explorerTarget = resolveWslFileManagerPath(target, env.WSL_DISTRO_NAME); + if (yield* isCommandAvailable(WSL_POWERSHELL_COMMAND, { env })) { + // Explorer's raw switch cannot express a double quote, and unlike + // Windows paths a WSL path may legally contain one: open the containing + // directory in File Explorer instead, matching the advertised + // "file-explorer" kind. + if (explorerTarget.includes('"')) { + const path = yield* Path.Path; + return { + editor: "file-manager", + target, + command: "explorer.exe", + args: [resolveWslFileManagerPath(path.dirname(target), env.WSL_DISTRO_NAME)], + }; + } + return fileExplorerRevealLaunch(target, explorerTarget, WSL_POWERSHELL_COMMAND); + } + // Without interop PowerShell the capability advertised the Linux "files" + // kind when it advertised anything at all, so the reveal must open the + // Linux file manager the label promised, not File Explorer. + if (hasGraphicalLinuxSession(env) && (yield* isUsableFileManagerCommand("xdg-open", env))) { + const path = yield* Path.Path; + return { editor: "file-manager", target, command: "xdg-open", args: [path.dirname(target)] }; + } + // Nothing was advertised here; open the parent in File Explorer as the + // best remaining effort for a stale client. + const path = yield* Path.Path; + return { + editor: "file-manager", + target, + command: "explorer.exe", + args: [resolveWslFileManagerPath(path.dirname(target), env.WSL_DISTRO_NAME)], + }; + } + + // Linux file managers have no portable "select this file" flag, so open + // the containing directory instead. + const path = yield* Path.Path; + return { editor: "file-manager", target, command, args: [path.dirname(target)] }; +}); + const launchAndUnref = Effect.fn("externalLauncher.launchAndUnref")(function* ( launch: ProcessLaunch, onError: (cause: unknown) => ExternalLauncherError, @@ -476,7 +767,9 @@ export const make = Effect.gen(function* () { if (Option.isSome(entry) && entry.value.expiresAtNanos > nowNanos) { return entry.value.editors; } - const editors = yield* provideCommandResolutionServices(resolveAvailableEditors()); + const editors = yield* provideCommandResolutionServices(resolveAvailableEditors()).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); yield* Ref.set( editorDiscoveryCache, Option.some({ @@ -489,18 +782,18 @@ export const make = Effect.gen(function* () { return ExternalLauncher.of({ resolveAvailableEditors: () => cachedAvailableEditors, + resolveFileManagerRevealKind: () => + provideCommandResolutionServices(resolveFileManagerRevealKind()).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ), launchBrowser: (target) => launchBrowser(target).pipe( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), ), launchEditor: (input) => provideCommandResolutionServices( - Effect.flatMap(resolveEditorLaunch(input), (launch) => - launchEditorProcess(launch).pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - ), - ), - ), + Effect.flatMap(resolveEditorLaunch(input), launchEditorProcess), + ).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)), }); }); diff --git a/apps/server/src/project/ProjectFaviconResolver.test.ts b/apps/server/src/project/ProjectFaviconResolver.test.ts index c610781ea9be..2c7b0f7bdc62 100644 --- a/apps/server/src/project/ProjectFaviconResolver.test.ts +++ b/apps/server/src/project/ProjectFaviconResolver.test.ts @@ -1,10 +1,12 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { it, describe, expect } from "@effect/vitest"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; +import { TestClock } from "effect/testing"; import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; import * as ProjectFaviconResolver from "./ProjectFaviconResolver.ts"; @@ -49,6 +51,64 @@ const makeResolverWithFileSystem = (fileSystem: FileSystem.FileSystem) => it.layer(TestLayer)("ProjectFaviconResolverLive", (it) => { describe("resolvePath", () => { + it.effect("serves repeated resolves from cache instead of re-walking candidates", () => + Effect.gen(function* () { + const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; + const cwd = yield* makeTempDir; + yield* writeTextFile(cwd, "public/favicon.svg", "public"); + + const resolved = yield* resolver.resolvePath(cwd); + expect(resolved?.endsWith("public/favicon.svg")).toBe(true); + + // `favicon.svg` outranks `public/favicon.svg`, so a resolver that walked + // the candidate list again would switch to it. Staying on the original + // answer is only possible from cache. + yield* writeTextFile(cwd, "favicon.svg", "root"); + + for (const _attempt of [1, 2, 3]) { + expect(yield* resolver.resolvePath(cwd)).toBe(resolved); + } + + yield* TestClock.adjust(Duration.minutes(11)); + + expect((yield* resolver.resolvePath(cwd))?.endsWith("/favicon.svg")).toBe(true); + expect(yield* resolver.resolvePath(cwd)).not.toBe(resolved); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("falls back at once when a cached favicon is deleted", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; + const cwd = yield* makeTempDir; + yield* writeTextFile(cwd, "favicon.svg", "favicon"); + + expect(yield* resolver.resolvePath(cwd)).not.toBeNull(); + + yield* fileSystem.remove(path.join(cwd, "favicon.svg")).pipe(Effect.orDie); + + // Still inside the positive TTL: the cached path must not be served. + expect(yield* resolver.resolvePath(cwd)).toBeNull(); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("re-probes for a favicon added after a miss once the negative TTL expires", () => + Effect.gen(function* () { + const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; + const cwd = yield* makeTempDir; + + expect(yield* resolver.resolvePath(cwd)).toBeNull(); + + yield* writeTextFile(cwd, "favicon.svg", "favicon"); + expect(yield* resolver.resolvePath(cwd)).toBeNull(); + + yield* TestClock.adjust(Duration.minutes(2)); + + expect(yield* resolver.resolvePath(cwd)).not.toBeNull(); + }).pipe(Effect.provide(TestClock.layer())), + ); + it.effect("prefers well-known favicon files", () => Effect.gen(function* () { const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; diff --git a/apps/server/src/project/ProjectFaviconResolver.ts b/apps/server/src/project/ProjectFaviconResolver.ts index 9d9a5bddc791..2b68f5310d90 100644 --- a/apps/server/src/project/ProjectFaviconResolver.ts +++ b/apps/server/src/project/ProjectFaviconResolver.ts @@ -6,8 +6,11 @@ * * @module ProjectFaviconResolver */ +import * as Cache from "effect/Cache"; import * as Context from "effect/Context"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -18,6 +21,30 @@ import * as Schema from "effect/Schema"; import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; import * as T3ProjectFileLoader from "./T3ProjectFileLoader.ts"; +// Resolution walks up to 12 well-known paths plus 7 source files, so a miss +// costs ~20 filesystem probes. AssetAccess resolves on every project-favicon +// asset URL, and a project's icon does not move, so the answer is cached. +const FAVICON_CACHE_CAPACITY = 512; +const FAVICON_POSITIVE_CACHE_TTL = Duration.minutes(10); +const FAVICON_NEGATIVE_CACHE_TTL = Duration.minutes(1); + +function faviconCacheKey(cwd: string, faviconPath?: string): string { + return `${faviconPath ?? ""}\0${cwd}`; +} + +function parseFaviconCacheKey(key: string): { + readonly cwd: string; + readonly faviconPath?: string; +} { + const separatorIndex = key.indexOf("\0"); + if (separatorIndex === -1) { + return { cwd: key }; + } + const faviconPath = key.slice(0, separatorIndex); + const cwd = key.slice(separatorIndex + 1); + return faviconPath.length === 0 ? { cwd } : { cwd, faviconPath }; +} + // Well-known favicon paths checked in order. const FAVICON_CANDIDATES = [ "favicon.svg", @@ -178,9 +205,10 @@ export const make = Effect.gen(function* () { return null; }); - const resolvePath: ProjectFaviconResolver["Service"]["resolvePath"] = Effect.fn( - "ProjectFaviconResolver.resolvePath", - )(function* (cwd, faviconPath) { + const resolvePathUncached = Effect.fn("ProjectFaviconResolver.resolvePathUncached")(function* ( + cwd: string, + faviconPath?: string, + ): Effect.fn.Return { const projectCwd = yield* workspacePaths.normalizeWorkspaceRoot(cwd).pipe( Effect.mapError( (cause) => @@ -267,6 +295,52 @@ export const make = Effect.gen(function* () { return null; }); + const faviconCache = yield* Cache.makeWith( + (key) => { + const { cwd, faviconPath } = parseFaviconCacheKey(key); + return resolvePathUncached(cwd, faviconPath); + }, + { + capacity: FAVICON_CACHE_CAPACITY, + timeToLive: Exit.match({ + onSuccess: (value: string | null) => + value === null ? FAVICON_NEGATIVE_CACHE_TTL : FAVICON_POSITIVE_CACHE_TTL, + onFailure: () => Duration.zero, + }), + }, + ); + + const resolvePath: ProjectFaviconResolver["Service"]["resolvePath"] = Effect.fn( + "ProjectFaviconResolver.resolvePath", + )(function* (cwd, faviconPath) { + const key = faviconCacheKey(cwd, faviconPath); + const cached = yield* Cache.get(faviconCache, key); + if (cached === null) { + return null; + } + + // A hit still confirms the file with one stat rather than the ~20 probes a + // full walk costs, so a deleted icon falls back at once instead of after + // the TTL. + const stats = yield* optionOnNotFound(fileSystem.stat(cached)).pipe( + Effect.mapError( + (cause) => + new ProjectFaviconResolutionError({ + operation: "stat-candidate", + workspaceRoot: cwd, + absolutePath: cached, + cause, + }), + ), + ); + if (Option.isSome(stats) && stats.value.type === "File") { + return cached; + } + + yield* Cache.invalidate(faviconCache, key); + return yield* Cache.get(faviconCache, key); + }); + return ProjectFaviconResolver.of({ resolvePath }); }); diff --git a/apps/server/src/project/RepositoryIdentityResolver.test.ts b/apps/server/src/project/RepositoryIdentityResolver.test.ts index a997459e63d7..72232a78b689 100644 --- a/apps/server/src/project/RepositoryIdentityResolver.test.ts +++ b/apps/server/src/project/RepositoryIdentityResolver.test.ts @@ -5,6 +5,7 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import { TestClock } from "effect/testing"; import * as ProcessRunner from "../processRunner.ts"; @@ -35,6 +36,89 @@ const makeRepositoryIdentityResolverTestLayer = (options: { ).pipe(Layer.provide(ProcessRunner.layer)); it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { + it.effect("reuses the cached Git root for repeated workspace lookups", () => { + const calls: Array> = []; + const processRunner = Layer.succeed(ProcessRunner.ProcessRunner, { + run: (input) => + Effect.sync(() => { + calls.push(input.args); + return { + stdout: input.args.includes("rev-parse") + ? "/repo\n" + : "origin\tgit@github.com:T3Tools/t3code.git (fetch)\n", + stderr: "", + code: ChildProcessSpawner.ExitCode(0), + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + stdoutInvalidUtf8: false, + stderrInvalidUtf8: false, + }; + }), + }); + const resolverLayer = Layer.effect( + RepositoryIdentityResolver.RepositoryIdentityResolver, + RepositoryIdentityResolver.make(), + ).pipe(Layer.provide(processRunner)); + + return Effect.gen(function* () { + const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; + const first = yield* resolver.resolve("/repo/packages/web"); + const second = yield* resolver.resolve("/repo/packages/web"); + + expect(first?.canonicalKey).toBe("github.com/t3tools/t3code"); + expect(second).toEqual(first); + expect(calls).toEqual([ + ["-C", "/repo/packages/web", "rev-parse", "--show-toplevel"], + ["-C", "/repo", "remote", "-v"], + ]); + }).pipe(Effect.provide(resolverLayer)); + }); + + it.effect("retries Git root discovery after a failed lookup", () => { + const calls: Array> = []; + let rootAttempts = 0; + const processRunner = Layer.succeed(ProcessRunner.ProcessRunner, { + run: (input) => + Effect.sync(() => { + calls.push(input.args); + const rootLookup = input.args.includes("rev-parse"); + const failed = rootLookup && rootAttempts++ === 0; + return { + stdout: rootLookup + ? failed + ? "" + : "/repo\n" + : "origin\tgit@github.com:T3Tools/t3code.git (fetch)\n", + stderr: failed ? "temporary Git failure" : "", + code: ChildProcessSpawner.ExitCode(failed ? 1 : 0), + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + stdoutInvalidUtf8: false, + stderrInvalidUtf8: false, + }; + }), + }); + const resolverLayer = Layer.effect( + RepositoryIdentityResolver.RepositoryIdentityResolver, + RepositoryIdentityResolver.make(), + ).pipe(Layer.provide(processRunner)); + + return Effect.gen(function* () { + const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; + expect(yield* resolver.resolve("/repo/packages/web")).toBeNull(); + + const recovered = yield* resolver.resolve("/repo/packages/web"); + expect(recovered?.rootPath).toBe("/repo"); + expect(calls).toEqual([ + ["-C", "/repo/packages/web", "rev-parse", "--show-toplevel"], + ["-C", "/repo/packages/web", "rev-parse", "--show-toplevel"], + ["-C", "/repo", "remote", "-v"], + ]); + }).pipe(Effect.provide(resolverLayer)); + }); + it.effect("normalizes equivalent GitHub remotes into a stable repository identity", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/project/RepositoryIdentityResolver.ts b/apps/server/src/project/RepositoryIdentityResolver.ts index 50608e7704c7..bf3c570c3cac 100644 --- a/apps/server/src/project/RepositoryIdentityResolver.ts +++ b/apps/server/src/project/RepositoryIdentityResolver.ts @@ -90,7 +90,6 @@ function buildRepositoryIdentity(input: { const resolveRepositoryIdentityCacheKey = Effect.fn("RepositoryIdentityResolver.resolveCacheKey")( function* (cwd: string) { const processRunner = yield* ProcessRunner.ProcessRunner; - let cacheKey = cwd; // git is a real executable on every platform — no cmd.exe shell mode, which // would split paths containing spaces during cmd's re-tokenization. @@ -102,15 +101,11 @@ const resolveRepositoryIdentityCacheKey = Effect.fn("RepositoryIdentityResolver. }) .pipe(Effect.option); if (topLevelResult._tag === "None" || topLevelResult.value.code !== 0) { - return cacheKey; + return null; } const candidate = topLevelResult.value.stdout.trim(); - if (candidate.length > 0) { - cacheKey = candidate; - } - - return cacheKey; + return candidate.length > 0 ? candidate : null; }, ); @@ -139,6 +134,22 @@ export const make = Effect.fn("RepositoryIdentityResolver.make")(function* ( options: RepositoryIdentityResolverOptions = {}, ) { const processRunner = yield* ProcessRunner.ProcessRunner; + const cacheCapacity = options.cacheCapacity ?? DEFAULT_REPOSITORY_IDENTITY_CACHE_CAPACITY; + + const repositoryRootCache = yield* Cache.makeWith( + (cwd) => + resolveRepositoryIdentityCacheKey(cwd).pipe( + Effect.provideService(ProcessRunner.ProcessRunner, processRunner), + ), + { + capacity: cacheCapacity, + timeToLive: Exit.match({ + onSuccess: (value) => + value === null ? Duration.zero : (options.positiveCacheTtl ?? DEFAULT_POSITIVE_CACHE_TTL), + onFailure: () => Duration.zero, + }), + }, + ); const repositoryIdentityCache = yield* Cache.makeWith( (cacheKey) => @@ -146,7 +157,7 @@ export const make = Effect.fn("RepositoryIdentityResolver.make")(function* ( Effect.provideService(ProcessRunner.ProcessRunner, processRunner), ), { - capacity: options.cacheCapacity ?? DEFAULT_REPOSITORY_IDENTITY_CACHE_CAPACITY, + capacity: cacheCapacity, timeToLive: Exit.match({ onSuccess: (value) => value === null @@ -160,9 +171,8 @@ export const make = Effect.fn("RepositoryIdentityResolver.make")(function* ( const resolve: RepositoryIdentityResolver["Service"]["resolve"] = Effect.fn( "RepositoryIdentityResolver.resolve", )(function* (cwd) { - const cacheKey = yield* resolveRepositoryIdentityCacheKey(cwd).pipe( - Effect.provideService(ProcessRunner.ProcessRunner, processRunner), - ); + const cacheKey = yield* Cache.get(repositoryRootCache, cwd); + if (cacheKey === null) return null; return yield* Cache.get(repositoryIdentityCache, cacheKey); }); diff --git a/apps/server/src/provider/ClaudeModelCatalog.test.ts b/apps/server/src/provider/ClaudeModelCatalog.test.ts new file mode 100644 index 000000000000..b370c8e24d3d --- /dev/null +++ b/apps/server/src/provider/ClaudeModelCatalog.test.ts @@ -0,0 +1,137 @@ +import { assert, describe, it } from "@effect/vitest"; +import { ProviderInstanceId } from "@t3tools/contracts"; + +import { hasValidClaudeManifestAdapters } from "./ClaudeModelManifest.ts"; +import type { ModelManifestData } from "./ModelManifest.ts"; +import { + formatClaudeVersionUpgradeMessage, + normalizeClaudeCatalogEffort, + resolveClaudeCatalogApiModelId, + resolveClaudeModelCatalog, + resolveClaudeModelsForVersion, + resolveClaudeModelSlug, +} from "./ClaudeModelCatalog.ts"; + +/** + * Test policy: adding or changing a real Claude model in model-manifest.json + * must not add or update tests here. These synthetic fixtures cover resolver + * behavior once. Add a test only when Claude adapter semantics change, such + * as introducing a new compatibility rule or dispatch mapping type. + */ + +const manifest = (): ModelManifestData => ({ + version: 1, + currentModels: {}, + providers: { + claudeAgent: { + profiles: { + synthetic: { + capabilities: { + optionDescriptors: [ + { + id: "effort", + label: "Reasoning", + type: "select", + options: [{ id: "extreme", label: "Extreme", isDefault: true }], + }, + { + id: "contextWindow", + label: "Context Window", + type: "select", + options: [{ id: "large", label: "Large", isDefault: true }], + }, + ], + }, + adapter: { + claudeCode: { + effortMap: { extreme: "high" }, + modelSuffixes: { contextWindow: { large: "[large]" } }, + }, + }, + }, + }, + models: [ + { + slug: "claude-synthetic-next", + name: "Claude Synthetic Next", + aliases: ["synthetic"], + status: "current", + profile: "synthetic", + adapter: { claudeCode: { minVersion: "3.2.0" } }, + }, + ], + }, + }, +}); + +describe("Claude model catalog", () => { + it("filters models at runtime-version boundaries and derives the upgrade message", () => { + const catalog = resolveClaudeModelCatalog(manifest()); + assert.deepStrictEqual(resolveClaudeModelsForVersion(catalog, "3.1.9"), []); + assert.deepStrictEqual( + resolveClaudeModelsForVersion(catalog, "3.2.0").map((model) => model.slug), + ["claude-synthetic-next"], + ); + assert.strictEqual( + formatClaudeVersionUpgradeMessage(catalog, "3.1.9"), + "Claude Code v3.1.9 is too old for Claude Synthetic Next. Upgrade to v3.2.0 or newer to access it.", + ); + }); + + it("resolves aliases and declarative adapter mappings", () => { + const base = manifest(); + const input: ModelManifestData = { + ...base, + providers: { + ...base.providers, + claudeAgent: { + ...base.providers!.claudeAgent!, + models: [ + { + slug: "claude-synthetic-collision", + name: "Claude Synthetic Collision", + aliases: ["claude-synthetic-next"], + status: "current", + }, + ...base.providers!.claudeAgent!.models, + ], + }, + }, + }; + const catalog = resolveClaudeModelCatalog(input); + assert.strictEqual(resolveClaudeModelSlug(catalog, "synthetic"), "claude-synthetic-next"); + assert.strictEqual( + resolveClaudeModelSlug(catalog, "claude-synthetic-next"), + "claude-synthetic-next", + ); + assert.strictEqual(normalizeClaudeCatalogEffort(catalog, "extreme", "synthetic"), "high"); + assert.strictEqual( + resolveClaudeCatalogApiModelId(catalog, { + instanceId: ProviderInstanceId.make("claudeAgent"), + model: "synthetic", + }), + "claude-synthetic-next[large]", + ); + }); + + it("rejects malformed adapter mappings", () => { + const base = manifest(); + const malformed: ModelManifestData = { + ...base, + providers: { + ...base.providers, + claudeAgent: { + ...base.providers!.claudeAgent!, + profiles: { + ...base.providers!.claudeAgent!.profiles, + synthetic: { + ...base.providers!.claudeAgent!.profiles.synthetic!, + adapter: { claudeCode: { effortMap: { extreme: 123 } } }, + }, + }, + }, + }, + }; + assert.isFalse(hasValidClaudeManifestAdapters(malformed)); + }); +}); diff --git a/apps/server/src/provider/ClaudeModelCatalog.testFixtures.ts b/apps/server/src/provider/ClaudeModelCatalog.testFixtures.ts new file mode 100644 index 000000000000..8fd9f5d76985 --- /dev/null +++ b/apps/server/src/provider/ClaudeModelCatalog.testFixtures.ts @@ -0,0 +1,83 @@ +import type { ClaudeModelCatalog } from "./ClaudeModelCatalog.ts"; + +// Transport tests must stay independent of bundled or remote manifest contents. +// Keep every model, alias, capability, and runtime mapping in this fixture synthetic. +export const SYNTHETIC_CLAUDE_CAPABLE_MODEL = "claude-synthetic-capable"; +export const SYNTHETIC_CLAUDE_COLLIDING_ALIAS = "synthetic-collision"; +export const SYNTHETIC_CLAUDE_STANDARD_MODEL = "claude-synthetic-standard"; +export const SYNTHETIC_CLAUDE_THINKING_MODEL = "claude-synthetic-thinking"; + +const effort = { + id: "effort", + label: "Reasoning", + type: "select" as const, + options: [ + { id: "low", label: "Low" }, + { id: "high", label: "High", isDefault: true }, + { id: "max", label: "Max" }, + { id: "ultrathink", label: "Ultrathink" }, + ], + promptInjectedValues: ["ultrathink"], +}; + +const contextWindow = { + id: "contextWindow", + label: "Context Window", + type: "select" as const, + options: [ + { id: "standard", label: "Standard" }, + { id: "expanded", label: "Expanded", isDefault: true }, + ], +}; + +const runtime = { + effortMap: { ultrathink: null }, + modelSuffixes: { contextWindow: { expanded: "[expanded]" } }, + contextWindowTokens: { standard: 200_000, expanded: 1_000_000 }, +}; + +export const SYNTHETIC_CLAUDE_MODEL_CATALOG: ClaudeModelCatalog = { + models: [ + { + model: { + slug: SYNTHETIC_CLAUDE_CAPABLE_MODEL, + name: "Claude Synthetic Capable", + aliases: [SYNTHETIC_CLAUDE_COLLIDING_ALIAS], + isCustom: false, + capabilities: { + optionDescriptors: [ + effort, + { id: "fastMode", label: "Fast Mode", type: "boolean" }, + contextWindow, + ], + }, + }, + runtime, + compatibility: {}, + }, + { + model: { + slug: SYNTHETIC_CLAUDE_STANDARD_MODEL, + name: "Claude Synthetic Standard", + isCustom: false, + capabilities: { + optionDescriptors: [effort, contextWindow], + }, + }, + runtime, + compatibility: {}, + }, + { + model: { + slug: SYNTHETIC_CLAUDE_THINKING_MODEL, + name: "Claude Synthetic Thinking", + isCustom: false, + capabilities: { + optionDescriptors: [{ id: "thinking", label: "Thinking", type: "boolean" }], + }, + }, + runtime: {}, + compatibility: {}, + }, + ], +}; diff --git a/apps/server/src/provider/ClaudeModelCatalog.ts b/apps/server/src/provider/ClaudeModelCatalog.ts new file mode 100644 index 000000000000..bd554f042f0b --- /dev/null +++ b/apps/server/src/provider/ClaudeModelCatalog.ts @@ -0,0 +1,242 @@ +import { + type ModelCapabilities, + type ModelSelection, + ProviderDriverKind, + type ServerProviderModel, +} from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import { + getModelSelectionStringOptionValue, + getProviderOptionCurrentValue, + getProviderOptionDescriptors, + normalizeCustomModelSlug, +} from "@t3tools/shared/model"; +import { compareSemverVersions } from "@t3tools/shared/semver"; + +import { + type ClaudeCodeCompatibility, + type ClaudeCodeProfile, + decodeClaudeModelAdapter, + decodeClaudeProfileAdapter, +} from "./ClaudeModelManifest.ts"; +import { + BUNDLED_MODEL_MANIFEST, + type ModelManifestData, + resolveProviderCatalog, +} from "./ModelManifest.ts"; + +const CLAUDE = ProviderDriverKind.make("claudeAgent"); +const EMPTY_CAPABILITIES: ModelCapabilities = { optionDescriptors: [] }; + +export interface ClaudeCatalogModel { + readonly model: ServerProviderModel; + readonly runtime: ClaudeCodeProfile; + readonly compatibility: ClaudeCodeCompatibility; +} + +export interface ClaudeModelCatalog { + readonly models: ReadonlyArray; +} + +function tryResolveClaudeModelCatalog(manifest: ModelManifestData): ClaudeModelCatalog | null { + const resolved = resolveProviderCatalog(manifest, CLAUDE); + if (!resolved) return null; + + const models: Array = []; + for (const entry of resolved.models) { + const profile = decodeClaudeProfileAdapter(entry.profileAdapter ?? {}); + const adapter = decodeClaudeModelAdapter(entry.adapter ?? {}); + if (Option.isNone(profile) || Option.isNone(adapter)) return null; + models.push({ + model: entry.model, + runtime: profile.value.claudeCode ?? {}, + compatibility: adapter.value.claudeCode ?? {}, + }); + } + + return { + models, + }; +} + +export function resolveClaudeModelCatalog(manifest: ModelManifestData): ClaudeModelCatalog { + return ( + tryResolveClaudeModelCatalog(manifest) ?? + tryResolveClaudeModelCatalog(BUNDLED_MODEL_MANIFEST) ?? { + models: [], + } + ); +} + +export const BUNDLED_CLAUDE_MODEL_CATALOG = resolveClaudeModelCatalog(BUNDLED_MODEL_MANIFEST); + +/** Keeps custom model aliases opaque while preserving canonical built-in models and capabilities. */ +export function scopeClaudeModelCatalog( + catalog: ClaudeModelCatalog, + customModels: ReadonlyArray, +): ClaudeModelCatalog { + const customAliases = new Set( + customModels.flatMap((model) => { + const slug = normalizeCustomModelSlug(model); + return slug ? [slug.toLowerCase()] : []; + }), + ); + if (customAliases.size === 0) return catalog; + + return { + models: catalog.models.map((entry) => { + if (!entry.model.aliases?.some((alias) => customAliases.has(alias.toLowerCase()))) { + return entry; + } + return { + ...entry, + model: { + ...entry.model, + aliases: entry.model.aliases.filter((alias) => !customAliases.has(alias.toLowerCase())), + }, + }; + }), + }; +} + +export function resolveClaudeCatalogModel( + catalog: ClaudeModelCatalog, + slugOrAlias: string | null | undefined, +): ClaudeCatalogModel | undefined { + const value = slugOrAlias?.trim(); + if (!value) return undefined; + return ( + catalog.models.find((entry) => entry.model.slug === value) ?? + catalog.models.find((entry) => + entry.model.aliases?.some((alias) => alias.toLowerCase() === value.toLowerCase()), + ) + ); +} + +export function resolveClaudeModelSlug(catalog: ClaudeModelCatalog, slugOrAlias: string): string { + return resolveClaudeCatalogModel(catalog, slugOrAlias)?.model.slug ?? slugOrAlias; +} + +export function getClaudeCatalogModelCapabilities( + catalog: ClaudeModelCatalog, + slugOrAlias: string | null | undefined, +): ModelCapabilities { + return resolveClaudeCatalogModel(catalog, slugOrAlias)?.model.capabilities ?? EMPTY_CAPABILITIES; +} + +function isVersionSupported( + compatibility: ClaudeCodeCompatibility, + version: string | null | undefined, +): boolean { + if (!compatibility.minVersion && !compatibility.maxVersionExclusive) return true; + if (!version) return false; + if (compatibility.minVersion && compareSemverVersions(version, compatibility.minVersion) < 0) { + return false; + } + return !( + compatibility.maxVersionExclusive && + compareSemverVersions(version, compatibility.maxVersionExclusive) >= 0 + ); +} + +export function resolveClaudeModelsForVersion( + catalog: ClaudeModelCatalog, + version: string | null | undefined, +): ReadonlyArray { + return catalog.models + .filter((entry) => isVersionSupported(entry.compatibility, version)) + .map((entry) => entry.model); +} + +export function formatClaudeVersionUpgradeMessage( + catalog: ClaudeModelCatalog, + version: string | null, +): string | undefined { + const unavailable = catalog.models + .filter( + (entry) => + entry.compatibility.minVersion && + (!version || compareSemverVersions(version, entry.compatibility.minVersion) < 0), + ) + .toSorted((left, right) => + compareSemverVersions(left.compatibility.minVersion!, right.compatibility.minVersion!), + )[0]; + if (!unavailable?.compatibility.minVersion) return undefined; + const versionLabel = version ? `v${version}` : "the installed version"; + return `Claude Code ${versionLabel} is too old for ${unavailable.model.name}. Upgrade to v${unavailable.compatibility.minVersion} or newer to access it.`; +} + +export function resolveClaudeCatalogEffort( + catalog: ClaudeModelCatalog, + model: string | null | undefined, + raw: string | null | undefined, +): string | undefined { + const caps = getClaudeCatalogModelCapabilities(catalog, model); + const descriptors = getProviderOptionDescriptors({ + caps, + ...(raw ? { selections: [{ id: "effort", value: raw }] } : {}), + }); + const descriptor = descriptors.find((candidate) => candidate.id === "effort"); + const value = getProviderOptionCurrentValue(descriptor); + return typeof value === "string" ? value : undefined; +} + +export function normalizeClaudeCatalogEffort( + catalog: ClaudeModelCatalog, + effort: string | null | undefined, + model: string | null | undefined, +): string | undefined { + if (!effort) return undefined; + const effortMap = resolveClaudeCatalogModel(catalog, model)?.runtime.effortMap; + if (!effortMap || !Object.prototype.hasOwnProperty.call(effortMap, effort)) return effort; + return effortMap[effort] ?? undefined; +} + +export function isClaudeCatalogUltracodeEffort(effort: string | null | undefined): boolean { + return effort === "ultracode"; +} + +export function resolveClaudeCatalogContextWindow( + catalog: ClaudeModelCatalog, + modelSelection: ModelSelection | undefined, +): string | undefined { + const caps = getClaudeCatalogModelCapabilities(catalog, modelSelection?.model); + const raw = getModelSelectionStringOptionValue(modelSelection, "contextWindow"); + const descriptors = getProviderOptionDescriptors({ + caps, + ...(raw ? { selections: [{ id: "contextWindow", value: raw }] } : {}), + }); + const descriptor = descriptors.find((candidate) => candidate.id === "contextWindow"); + const value = getProviderOptionCurrentValue(descriptor); + return typeof value === "string" ? value : undefined; +} + +export function resolveClaudeCatalogApiModelId( + catalog: ClaudeModelCatalog, + modelSelection: ModelSelection, +): string { + const entry = resolveClaudeCatalogModel(catalog, modelSelection.model); + const slug = entry?.model.slug ?? modelSelection.model; + const descriptors = getProviderOptionDescriptors({ + caps: entry?.model.capabilities ?? EMPTY_CAPABILITIES, + selections: modelSelection.options, + }); + for (const [optionId, suffixes] of Object.entries(entry?.runtime.modelSuffixes ?? {})) { + const value = getProviderOptionCurrentValue( + descriptors.find((descriptor) => descriptor.id === optionId), + ); + if (typeof value === "string" && suffixes[value]) return `${slug}${suffixes[value]}`; + } + return slug; +} + +export function resolveClaudeCatalogContextWindowTokens( + catalog: ClaudeModelCatalog, + modelSelection: ModelSelection | undefined, +): number | undefined { + const entry = resolveClaudeCatalogModel(catalog, modelSelection?.model); + if (!entry) return undefined; + if (entry.runtime.fixedContextWindowTokens) return entry.runtime.fixedContextWindowTokens; + const contextWindow = resolveClaudeCatalogContextWindow(catalog, modelSelection); + return contextWindow ? entry.runtime.contextWindowTokens?.[contextWindow] : undefined; +} diff --git a/apps/server/src/provider/ClaudeModelManifest.ts b/apps/server/src/provider/ClaudeModelManifest.ts new file mode 100644 index 000000000000..1bac30b2ce30 --- /dev/null +++ b/apps/server/src/provider/ClaudeModelManifest.ts @@ -0,0 +1,82 @@ +import { TrimmedNonEmptyString } from "@t3tools/contracts"; +import { compareSemverVersions, parseSemver } from "@t3tools/shared/semver"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +export const ClaudeCodeProfileSchema = Schema.Struct({ + effortMap: Schema.optional( + Schema.Record(TrimmedNonEmptyString, Schema.NullOr(TrimmedNonEmptyString)), + ), + modelSuffixes: Schema.optional( + Schema.Record( + TrimmedNonEmptyString, + Schema.Record(TrimmedNonEmptyString, TrimmedNonEmptyString), + ), + ), + contextWindowTokens: Schema.optional(Schema.Record(TrimmedNonEmptyString, Schema.Number)), + fixedContextWindowTokens: Schema.optional(Schema.Number), +}); + +export const ClaudeProfileAdapterSchema = Schema.Struct({ + claudeCode: Schema.optional(ClaudeCodeProfileSchema), +}); + +const ClaudeVersionSchema = TrimmedNonEmptyString.pipe( + Schema.check( + Schema.makeFilter((version) => parseSemver(version) !== null, { + expected: "a supported semantic version", + }), + ), +); + +const ClaudeCodeCompatibilitySchema = Schema.Struct({ + minVersion: Schema.optional(ClaudeVersionSchema), + maxVersionExclusive: Schema.optional(ClaudeVersionSchema), +}).pipe( + Schema.check( + Schema.makeFilter( + ({ minVersion, maxVersionExclusive }) => + minVersion === undefined || + maxVersionExclusive === undefined || + compareSemverVersions(minVersion, maxVersionExclusive) < 0, + { expected: "a minimum version below the exclusive maximum version" }, + ), + ), +); + +export const ClaudeModelAdapterSchema = Schema.Struct({ + claudeCode: Schema.optional(ClaudeCodeCompatibilitySchema), +}); + +export type ClaudeCodeProfile = typeof ClaudeCodeProfileSchema.Type; +export type ClaudeCodeCompatibility = NonNullable; + +export const decodeClaudeProfileAdapter = Schema.decodeUnknownOption(ClaudeProfileAdapterSchema); +export const decodeClaudeModelAdapter = Schema.decodeUnknownOption(ClaudeModelAdapterSchema); + +interface ClaudeManifestAdapterInput { + readonly providers?: + | Readonly< + Record< + string, + | { + readonly profiles: Readonly>; + readonly models: ReadonlyArray<{ readonly adapter?: unknown }>; + } + | undefined + > + > + | undefined; +} + +export function hasValidClaudeManifestAdapters(manifest: ClaudeManifestAdapterInput): boolean { + const catalog = manifest.providers?.claudeAgent; + if (!catalog) return true; + + return ( + Object.values(catalog.profiles).every((profile) => + Option.isSome(decodeClaudeProfileAdapter(profile.adapter ?? {})), + ) && + catalog.models.every((model) => Option.isSome(decodeClaudeModelAdapter(model.adapter ?? {}))) + ); +} diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index e099d52e5189..f1606bea7d76 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -12,7 +12,7 @@ * * @module provider/Drivers/ClaudeDriver */ -import { ClaudeSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; +import { ClaudeSettings, ProviderDriverKind } from "@t3tools/contracts"; import * as Cache from "effect/Cache"; import * as Duration from "effect/Duration"; import * as Crypto from "effect/Crypto"; @@ -35,13 +35,15 @@ import { probeClaudeCapabilities, } from "../Layers/ClaudeProvider.ts"; import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; +import { resolveClaudeModelCatalog } from "../ClaudeModelCatalog.ts"; import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; +import * as ModelManifest from "../ModelManifest.ts"; import { defaultProviderContinuationIdentity, type ProviderDriver, type ProviderInstance, } from "../ProviderDriver.ts"; -import type { ServerProviderDraft } from "../providerSnapshot.ts"; +import { withInstanceIdentity } from "./instanceIdentity.ts"; import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; import { enrichProviderSnapshotWithVersionAdvisory, @@ -87,27 +89,12 @@ export type ClaudeDriverEnv = | Crypto.Crypto | FileSystem.FileSystem | HttpClient.HttpClient + | ModelManifest.ModelManifest | Path.Path | ProviderEventLoggers | ServerConfig | ServerSettingsService; -const withInstanceIdentity = - (input: { - readonly instanceId: ProviderInstance["instanceId"]; - readonly displayName: string | undefined; - readonly accentColor: string | undefined; - readonly continuationGroupKey: string; - }) => - (snapshot: ServerProviderDraft): ServerProvider => ({ - ...snapshot, - instanceId: input.instanceId, - driver: DRIVER_KIND, - ...(input.displayName ? { displayName: input.displayName } : {}), - ...(input.accentColor ? { accentColor: input.accentColor } : {}), - continuation: { groupKey: input.continuationGroupKey }, - }); - export const ClaudeDriver: ProviderDriver = { driverKind: DRIVER_KIND, metadata: { @@ -125,6 +112,8 @@ export const ClaudeDriver: ProviderDriver = { const httpClient = yield* HttpClient.HttpClient; const serverSettings = yield* ServerSettingsService; const eventLoggers = yield* ProviderEventLoggers; + const modelManifest = yield* ModelManifest.ModelManifest; + const modelCatalog = modelManifest.current.pipe(Effect.map(resolveClaudeModelCatalog)); const processEnv = mergeProviderInstanceEnvironment(environment); const fallbackContinuationIdentity = defaultProviderContinuationIdentity({ driverKind: DRIVER_KIND, @@ -138,6 +127,7 @@ export const ClaudeDriver: ProviderDriver = { const continuationGroupKey = yield* makeClaudeContinuationGroupKey(effectiveConfig); const stampIdentity = withInstanceIdentity({ instanceId, + driverKind: DRIVER_KIND, displayName, accentColor, continuationGroupKey, @@ -146,10 +136,15 @@ export const ClaudeDriver: ProviderDriver = { const adapterOptions = { instanceId, environment: processEnv, + modelCatalog, ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), }; const adapter = yield* makeClaudeAdapter(effectiveConfig, adapterOptions); - const textGeneration = yield* makeClaudeTextGeneration(effectiveConfig, processEnv); + const textGeneration = yield* makeClaudeTextGeneration( + effectiveConfig, + processEnv, + modelCatalog, + ); // Per-instance capabilities cache: keyed on binary + resolved HOME so // account-specific probes never share auth metadata across instances. @@ -163,13 +158,23 @@ export const ClaudeDriver: ProviderDriver = { }); const capabilitiesCacheKey = yield* makeClaudeCapabilitiesCacheKey(effectiveConfig, cwd); - const checkProvider = checkClaudeProviderStatus( - effectiveConfig, - () => Cache.get(capabilitiesProbeCache, capabilitiesCacheKey), - processEnv, - cwd, - ).pipe( - Effect.map(stampIdentity), + // Start the TTL-gated refresh without delaying provider readiness. The + // next check observes a remote manifest after the background fetch lands. + const checkProvider = modelManifest.refreshInBackground.pipe( + Effect.andThen( + modelManifest.current.pipe( + Effect.flatMap((manifest) => + checkClaudeProviderStatus( + effectiveConfig, + () => Cache.get(capabilitiesProbeCache, capabilitiesCacheKey), + processEnv, + cwd, + resolveClaudeModelCatalog(manifest), + ), + ), + Effect.map(stampIdentity), + ), + ), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(Path.Path, path), @@ -182,7 +187,12 @@ export const ClaudeDriver: ProviderDriver = { streamSettings: snapshotSettings.streamSettings, haveSettingsChanged: haveProviderSnapshotSettingsChanged, initialSnapshot: (settings) => - makePendingClaudeProvider(settings.provider).pipe(Effect.map(stampIdentity)), + modelManifest.current.pipe( + Effect.flatMap((manifest) => + makePendingClaudeProvider(settings.provider, resolveClaudeModelCatalog(manifest)), + ), + Effect.map(stampIdentity), + ), checkProvider, enrichSnapshot: ({ settings, snapshot, publishSnapshot }) => enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenanceCapabilities, { diff --git a/apps/server/src/provider/Drivers/ClaudeSkillDispatch.test.ts b/apps/server/src/provider/Drivers/ClaudeSkillDispatch.test.ts new file mode 100644 index 000000000000..99074c8b07ed --- /dev/null +++ b/apps/server/src/provider/Drivers/ClaudeSkillDispatch.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { planClaudeSkillDispatch } from "./ClaudeSkillDispatch.ts"; + +const SKILLS = new Set(["implement", "review", "re-release-version"]); + +describe("planClaudeSkillDispatch", () => { + it("leaves a prompt without a known skill untouched", () => { + expect(planClaudeSkillDispatch("fix the build", SKILLS)).toBeUndefined(); + // Not a discovered skill, so it stays prose rather than becoming a command. + expect(planClaudeSkillDispatch("echo $HOME then $unknown", SKILLS)).toBeUndefined(); + }); + + it("moves a mid-prompt mention into a trailing slash command", () => { + expect(planClaudeSkillDispatch("ok, now $implement all the tickets", SKILLS)).toEqual({ + leadingText: "ok, now", + commandText: "/implement all the tickets", + skillName: "implement", + }); + }); + + it("keeps a mention that opens the prompt as a single command block", () => { + expect(planClaudeSkillDispatch("$review\nfocus on auth", SKILLS)).toEqual({ + leadingText: undefined, + commandText: "/review\nfocus on auth", + skillName: "review", + }); + }); + + it("dispatches the last mention and rewrites earlier ones inline", () => { + expect(planClaudeSkillDispatch("$review the diff, then $implement the fixes", SKILLS)).toEqual({ + leadingText: "/review the diff, then", + commandText: "/implement the fixes", + skillName: "implement", + }); + }); + + it("ignores a dollar token glued to other text", () => { + expect(planClaudeSkillDispatch("cost is 5$implement", SKILLS)).toBeUndefined(); + }); +}); diff --git a/apps/server/src/provider/Drivers/ClaudeSkillDispatch.ts b/apps/server/src/provider/Drivers/ClaudeSkillDispatch.ts new file mode 100644 index 000000000000..a008e0f9ec9b --- /dev/null +++ b/apps/server/src/provider/Drivers/ClaudeSkillDispatch.ts @@ -0,0 +1,78 @@ +/** + * ClaudeSkillDispatch — turns `$skill` mentions in a composer prompt into the + * slash invocation Claude Code actually runs. + * + * The composer inserts `$name` for every provider. Codex parses that natively; + * Claude Code does not, and treats it as prose. Claude Code's only user-side + * invocation is a text block whose first character is `/`: the harness + * expands `/name args` into the SKILL.md body, and every character after the + * name (newlines included) arrives as `ARGUMENTS`. Verified against the CLI in + * stream-json mode, which is what the Agent SDK uses: + * + * - The check runs on the LAST text block of the message. Earlier text + * blocks are preserved verbatim, and image blocks may sit before it. + * - Leading whitespace, or a `/name` that starts a later line of the same + * block, is literal text. + * - Only one skill expands per message; a second `/x` becomes argument text + * (anthropics/claude-code#87113). The model still starts the rest through + * its Skill tool when it reads `/name` in the prompt, so earlier mentions + * are rewritten to `/name` inline. + * + * So one mention anywhere in the prompt becomes a guaranteed invocation, and + * the user's text on either side is kept in order. + * + * @module provider/Drivers/ClaudeSkillDispatch + */ + +/** + * Same token shape the composer and timeline chips recognise + * (`packages/shared/src/composerInlineTokens.ts`), so a rendered chip and a + * dispatched skill are always the same set. + */ +const SKILL_MENTION_PATTERN = /(^|\s)\$([a-zA-Z][a-zA-Z0-9:_-]*)(?=\s|$)/g; + +export interface ClaudeSkillDispatch { + /** Text before the dispatched mention, or `undefined` when it opens the prompt. */ + readonly leadingText: string | undefined; + /** `/name` plus the trailing text, ready to be the message's last text block. */ + readonly commandText: string; + readonly skillName: string; +} + +/** + * Split `prompt` around the last `$skill` mention that names a known skill. + * Returns `undefined` when there is nothing to dispatch, in which case the + * prompt should go out unchanged. Mentions that do not match a discovered + * skill stay literal: a `$HOME` in prose must not become a command. + */ +export function planClaudeSkillDispatch( + prompt: string, + skillNames: ReadonlySet, +): ClaudeSkillDispatch | undefined { + const mentions = [...prompt.matchAll(SKILL_MENTION_PATTERN)].flatMap((match) => { + const name = match[2] ?? ""; + if (!skillNames.has(name)) return []; + const start = (match.index ?? 0) + (match[1]?.length ?? 0); + return [{ name, start, end: start + name.length + 1 }]; + }); + const last = mentions.at(-1); + if (!last) { + return undefined; + } + + const leading = prompt.slice(0, last.start); + const trailing = prompt.slice(last.end); + const leadingWithInlineSlashes = mentions + .slice(0, -1) + .reduceRight( + (text, mention) => `${text.slice(0, mention.start)}/${text.slice(mention.start + 1)}`, + leading, + ) + .trimEnd(); + + return { + leadingText: leadingWithInlineSlashes.length > 0 ? leadingWithInlineSlashes : undefined, + commandText: `/${last.name}${trailing}`.trimEnd(), + skillName: last.name, + }; +} diff --git a/apps/server/src/provider/Drivers/ClaudeSkills.test.ts b/apps/server/src/provider/Drivers/ClaudeSkills.test.ts index 60db1d0c5e26..3e46ba94df03 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkills.test.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkills.test.ts @@ -4,7 +4,7 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; -import { discoverClaudeSkills } from "./ClaudeSkills.ts"; +import { discoverClaudeSkills, skillOverrideSettingsPaths } from "./ClaudeSkills.ts"; const writeSkill = Effect.fn(function* ( skillsDir: string, @@ -66,7 +66,7 @@ it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => { }), ); - it.effect("discovers project skills from the workspace .agents directory", () => + it.effect("ignores .agents/skills, which Claude Code does not load", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -74,6 +74,8 @@ it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => { const configDir = path.join(tempDir, "claude-home"); const workspace = path.join(tempDir, "workspace"); + // Verified against the CLI: `/review` here is answered with + // `Unknown command`, so offering it would dispatch a dead command. yield* writeSkill( path.join(workspace, ".agents", "skills"), "review", @@ -82,19 +84,11 @@ it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => { const skills = yield* discoverClaudeSkills({ homePath: configDir }, workspace); - assert.deepEqual(skills, [ - { - name: "review", - path: path.join(workspace, ".agents", "skills", "review", "SKILL.md"), - enabled: true, - scope: "project", - description: "Review the changes.", - }, - ]); + assert.deepEqual(skills, []); }), ); - it.effect("prefers workspace .claude skills on three-way name collisions", () => + it.effect("prefers user skills on name collisions even with a stray .agents copy", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -123,49 +117,16 @@ it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => { assert.deepEqual(skills, [ { name: "deploy", - path: path.join(workspace, ".claude", "skills", "deploy", "SKILL.md"), + path: path.join(configDir, "skills", "deploy", "SKILL.md"), enabled: true, - scope: "project", - description: "Claude deploy.", - }, - ]); - }), - ); - - it.effect("prefers workspace .agents skills over user skills on name collisions", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" }); - const configDir = path.join(tempDir, "claude-home"); - const workspace = path.join(tempDir, "workspace"); - - yield* writeSkill( - path.join(configDir, "skills"), - "deploy", - ["---", "name: deploy", "description: User deploy.", "---"].join("\n"), - ); - yield* writeSkill( - path.join(workspace, ".agents", "skills"), - "deploy", - ["---", "name: deploy", "description: Agents deploy.", "---"].join("\n"), - ); - - const skills = yield* discoverClaudeSkills({ homePath: configDir }, workspace); - - assert.deepEqual(skills, [ - { - name: "deploy", - path: path.join(workspace, ".agents", "skills", "deploy", "SKILL.md"), - enabled: true, - scope: "project", - description: "Agents deploy.", + scope: "user", + description: "User deploy.", }, ]); }), ); - it.effect("prefers project skills over user skills on name collisions", () => + it.effect("prefers user skills over project skills on name collisions", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -187,8 +148,8 @@ it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => { const skills = yield* discoverClaudeSkills({ homePath: configDir }, workspace); assert.equal(skills.length, 1); - assert.equal(skills[0]?.scope, "project"); - assert.equal(skills[0]?.description, "Project deploy."); + assert.equal(skills[0]?.scope, "user"); + assert.equal(skills[0]?.description, "User deploy."); }), ); @@ -287,6 +248,424 @@ it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => { }), ); + it.effect("marks skills that only the user can invoke", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" }); + const configDir = path.join(tempDir, "claude-home"); + const workspace = path.join(tempDir, "workspace"); + + yield* writeSkill( + path.join(workspace, ".claude", "skills"), + "re-release-version", + [ + "---", + "name: re-release-version", + "description: Move the current tag forward.", + "disable-model-invocation: true", + "---", + "", + "# Body", + ].join("\n"), + ); + yield* writeSkill( + path.join(workspace, ".claude", "skills"), + "release-version", + ["---", "name: release-version", "description: Cut a release.", "---", "", "# Body"].join( + "\n", + ), + ); + + const skills = yield* discoverClaudeSkills({ homePath: configDir }, workspace); + + assert.equal( + skills.find((skill) => skill.name === "re-release-version")?.userInvocationOnly, + true, + ); + assert.equal( + skills.find((skill) => skill.name === "release-version")?.userInvocationOnly, + undefined, + ); + }), + ); + + it.effect("disables skills switched off by skillOverrides", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" }); + const configDir = path.join(tempDir, "claude-home"); + const workspace = path.join(tempDir, "workspace"); + + for (const name of ["kept", "off-by-user", "off-by-project"]) { + yield* writeSkill( + path.join(configDir, "skills"), + name, + ["---", `name: ${name}`, "---", "", "# Body"].join("\n"), + ); + } + + yield* fs.makeDirectory(configDir, { recursive: true }); + yield* fs.writeFileString( + path.join(configDir, "settings.json"), + '{ "skillOverrides": { "off-by-user": "off", "kept": "on" } }', + ); + yield* fs.makeDirectory(path.join(workspace, ".claude"), { recursive: true }); + yield* fs.writeFileString( + path.join(workspace, ".claude", "settings.json"), + '{ "skillOverrides": { "off-by-project": "off" } }', + ); + + const skills = yield* discoverClaudeSkills({ homePath: configDir }, workspace); + + assert.deepEqual( + skills.map((skill) => [skill.name, skill.enabled]), + [ + ["kept", true], + ["off-by-project", false], + ["off-by-user", false], + ], + ); + }), + ); + + it.effect("ignores unreadable settings when resolving skillOverrides", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" }); + const configDir = path.join(tempDir, "claude-home"); + + yield* writeSkill( + path.join(configDir, "skills"), + "kept", + ["---", "name: kept", "---", "", "# Body"].join("\n"), + ); + yield* fs.writeFileString(path.join(configDir, "settings.json"), "{ not json"); + + const skills = yield* discoverClaudeSkills({ homePath: configDir }); + + assert.deepEqual( + skills.map((skill) => [skill.name, skill.enabled]), + [["kept", true]], + ); + }), + ); + + it.effect("treats a user-invocable-only override like disable-model-invocation", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" }); + const configDir = path.join(tempDir, "claude-home"); + + yield* writeSkill( + path.join(configDir, "skills"), + "ask-matt", + ["---", "name: ask-matt", "---", "", "# Body"].join("\n"), + ); + yield* fs.writeFileString( + path.join(configDir, "settings.json"), + '{ "skillOverrides": { "ask-matt": "user-invocable-only" } }', + ); + + const skills = yield* discoverClaudeSkills({ homePath: configDir }); + + assert.deepEqual( + skills.map((skill) => [skill.name, skill.enabled, skill.userInvocationOnly === true]), + [["ask-matt", true, true]], + ); + }), + ); + + it.effect("drops every override in a file when one value is invalid, as Claude Code does", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" }); + const configDir = path.join(tempDir, "claude-home"); + + for (const name of ["unknown-mode", "boolean-false", "sibling-off"]) { + yield* writeSkill( + path.join(configDir, "skills"), + name, + ["---", `name: ${name}`, "---", "", "# Body"].join("\n"), + ); + } + // Verified against the CLI: with an unknown string or a boolean in the + // map, the valid "off" sibling is ignored too and every skill runs. + yield* fs.writeFileString( + path.join(configDir, "settings.json"), + '{ "skillOverrides": { "unknown-mode": "some-future-mode", "boolean-false": false, "sibling-off": "off" } }', + ); + + const skills = yield* discoverClaudeSkills({ homePath: configDir }); + + assert.deepEqual( + skills.map((skill) => [skill.name, skill.enabled]), + [ + ["boolean-false", true], + ["sibling-off", true], + ["unknown-mode", true], + ], + ); + }), + ); + + it.effect("reads repository root settings from a nested workspace", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" }); + const configDir = path.join(tempDir, "claude-home"); + const repo = path.join(tempDir, "repo"); + const workspace = path.join(repo, "packages", "app"); + + for (const name of ["root-off", "root-off-cwd-on", "cwd-off-root-on"]) { + yield* writeSkill( + path.join(configDir, "skills"), + name, + ["---", `name: ${name}`, "---", "", "# Body"].join("\n"), + ); + } + yield* fs.makeDirectory(path.join(repo, ".git"), { recursive: true }); + yield* fs.makeDirectory(path.join(repo, ".claude"), { recursive: true }); + yield* fs.makeDirectory(path.join(workspace, ".claude"), { recursive: true }); + // The CLI ignores the root's plain settings.json from a nested cwd. + yield* fs.writeFileString( + path.join(repo, ".claude", "settings.json"), + '{ "skillOverrides": { "cwd-off-root-on": "off" } }', + ); + // The root local file outranks the workspace local file, as in the CLI. + yield* fs.writeFileString( + path.join(repo, ".claude", "settings.local.json"), + '{ "skillOverrides": { "root-off": "off", "root-off-cwd-on": "off", "cwd-off-root-on": "on" } }', + ); + yield* fs.writeFileString( + path.join(workspace, ".claude", "settings.local.json"), + '{ "skillOverrides": { "root-off-cwd-on": "on", "cwd-off-root-on": "off" } }', + ); + + const skills = yield* discoverClaudeSkills({ homePath: configDir }, workspace); + + assert.deepEqual( + skills.map((skill) => [skill.name, skill.enabled]), + [ + ["cwd-off-root-on", true], + ["root-off", false], + ["root-off-cwd-on", false], + ], + ); + }), + ); + + it.effect("ignores ancestor settings outside a repository", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" }); + const configDir = path.join(tempDir, "claude-home"); + const parent = path.join(tempDir, "not-a-repo"); + const workspace = path.join(parent, "workspace"); + + yield* writeSkill( + path.join(configDir, "skills"), + "kept", + ["---", "name: kept", "---", "", "# Body"].join("\n"), + ); + yield* fs.makeDirectory(path.join(parent, ".claude"), { recursive: true }); + yield* fs.makeDirectory(workspace, { recursive: true }); + yield* fs.writeFileString( + path.join(parent, ".claude", "settings.local.json"), + '{ "skillOverrides": { "kept": "off" } }', + ); + + const skills = yield* discoverClaudeSkills({ homePath: configDir }, workspace); + + assert.deepEqual( + skills.map((skill) => [skill.name, skill.enabled]), + [["kept", true]], + ); + }), + ); + + it.effect("lets the administrator's managed policy outrank every other settings file", () => + Effect.gen(function* () { + const path = yield* Path.Path; + + for (const [platform, expected] of [ + ["darwin", "/Library/Application Support/ClaudeCode/managed-settings.json"], + ["linux", "/etc/claude-code/managed-settings.json"], + ] as const) { + const paths = skillOverrideSettingsPaths(path, "/home/.claude", "/workspace", platform, {}); + assert.deepEqual(paths, [ + "/home/.claude/settings.json", + "/workspace/.claude/settings.json", + "/workspace/.claude/settings.local.json", + expected, + ]); + } + + assert.deepEqual( + skillOverrideSettingsPaths(path, "/home/.claude", undefined, "win32", { + PROGRAMDATA: "C:/ProgramData", + }).at(-1), + "C:/ProgramData/ClaudeCode/managed-settings.json", + ); + assert.deepEqual(skillOverrideSettingsPaths(path, "/home/.claude", undefined, "win32", {}), [ + "/home/.claude/settings.json", + ]); + + // Only the repository root's local file joins in, after the + // workspace's own local file so it wins. + assert.deepEqual( + skillOverrideSettingsPaths( + path, + "/home/.claude", + "/repo/packages/app", + "linux", + {}, + "/repo", + ), + [ + "/home/.claude/settings.json", + "/repo/packages/app/.claude/settings.json", + "/repo/packages/app/.claude/settings.local.json", + "/repo/.claude/settings.local.json", + "/etc/claude-code/managed-settings.json", + ], + ); + // A workspace that is the root itself is not read twice. + assert.deepEqual( + skillOverrideSettingsPaths(path, "/home/.claude", "/repo", "linux", {}, "/repo"), + [ + "/home/.claude/settings.json", + "/repo/.claude/settings.json", + "/repo/.claude/settings.local.json", + "/etc/claude-code/managed-settings.json", + ], + ); + }), + ); + + it.effect("records a skill Claude Code keeps out of its own slash commands", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" }); + const configDir = path.join(tempDir, "claude-home"); + + yield* writeSkill( + path.join(configDir, "skills"), + "agent-only", + ["---", "name: agent-only", "user-invocable: false", "---", "", "# Body"].join("\n"), + ); + + const skills = yield* discoverClaudeSkills({ homePath: configDir }); + + assert.deepEqual( + skills.map((skill) => [skill.name, skill.userInvocable]), + [["agent-only", false]], + ); + }), + ); + + it.effect("identifies a skill by its directory, as Claude Code does", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" }); + const configDir = path.join(tempDir, "claude-home"); + + yield* writeSkill( + path.join(configDir, "skills"), + "probe-alias", + ["---", "name: probe-alias-frontmatter", "---", "", "# Body"].join("\n"), + ); + yield* fs.writeFileString( + path.join(configDir, "settings.json"), + '{ "skillOverrides": { "probe-alias-frontmatter": "off" } }', + ); + + const skills = yield* discoverClaudeSkills({ homePath: configDir }); + + // The frontmatter name is not the command, so an override naming it is + // not the override Claude Code would apply either. + assert.deepEqual( + skills.map((skill) => [skill.name, skill.enabled]), + [["probe-alias", true]], + ); + }), + ); + + it.effect("switches a skill off by its directory name", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" }); + const configDir = path.join(tempDir, "claude-home"); + + yield* writeSkill( + path.join(configDir, "skills"), + "probe-alias", + ["---", "name: probe-alias-frontmatter", "---", "", "# Body"].join("\n"), + ); + yield* fs.writeFileString( + path.join(configDir, "settings.json"), + '{ "skillOverrides": { "probe-alias": "off" } }', + ); + + const skills = yield* discoverClaudeSkills({ homePath: configDir }); + + assert.deepEqual( + skills.map((skill) => [skill.name, skill.enabled]), + [["probe-alias", false]], + ); + }), + ); + + it.effect("accepts the YAML 1.1 boolean spellings Claude Code allows", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" }); + const configDir = path.join(tempDir, "claude-home"); + const skillsDir = path.join(configDir, "skills"); + + yield* writeSkill( + skillsDir, + "user-only-yes", + ["---", "disable-model-invocation: yes", "---", "", "# Body"].join("\n"), + ); + yield* writeSkill( + skillsDir, + "agent-only-no", + ["---", "user-invocable: no", "---", "", "# Body"].join("\n"), + ); + yield* writeSkill( + skillsDir, + "plain-off", + ["---", "disable-model-invocation: off", "---", "", "# Body"].join("\n"), + ); + + const skills = yield* discoverClaudeSkills({ homePath: configDir }); + + assert.deepEqual( + skills.map((skill) => [ + skill.name, + skill.userInvocationOnly === true, + skill.userInvocable === false, + ]), + [ + ["agent-only-no", false, true], + ["plain-off", false, false], + ["user-only-yes", true, false], + ], + ); + }), + ); + it.effect("returns an empty list when no skill roots exist", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/provider/Drivers/ClaudeSkills.ts b/apps/server/src/provider/Drivers/ClaudeSkills.ts index 5c33fba0b9e9..236fe79f518c 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkills.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkills.ts @@ -1,10 +1,12 @@ /** * ClaudeSkills — filesystem discovery of Claude Code skills for the `$` picker. * - * Claude Code loads skills from `/skills` (user scope), then - * `/.agents/skills` and `/.claude/skills` (project scope), one - * directory per skill with a `SKILL.md` carrying YAML frontmatter. Later roots - * win on name collisions, so precedence is user, `.agents`, then `.claude`. + * Claude Code loads skills from `/skills` (user scope) and + * `/.claude/skills` (project scope), one directory per skill with a + * `SKILL.md` carrying YAML frontmatter. The user root wins on name collisions, + * matching the CLI. `.agents/skills` is a Codex location: verified against the + * CLI, a skill that lives only there is answered with `Unknown command`, so it + * is not scanned here. * The Agent SDK init handshake surfaces skills only as slash commands without * their filesystem paths, so the provider snapshot scans the same locations * directly, mirroring how the Codex app-server reports its skills. @@ -17,6 +19,9 @@ import type { ClaudeSettings, ServerProviderSkill } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { fromLenientJson } from "@t3tools/shared/schemaJson"; import { parse as parseYamlDocument } from "yaml"; import { expandHomePath } from "../../pathExpansion.ts"; @@ -28,7 +33,41 @@ const FRONTMATTER_PATTERN = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/; type SkillFrontmatter = | { readonly kind: "missing" } | { readonly kind: "malformed" } - | { readonly kind: "parsed"; readonly name?: string; readonly description?: string }; + | { + readonly kind: "parsed"; + readonly description?: string; + readonly userInvocationOnly?: boolean; + readonly userInvocable?: boolean; + }; + +/** + * Claude Code accepts the YAML 1.1 boolean spellings (`yes`/`no`, `on`/`off`, + * `1`/`0`), which the 1.2 core schema this parser uses leaves as strings and + * numbers. Verified against the CLI: a skill carrying `user-invocable: no` is + * absent from its published slash commands, so a strict `=== false` here would + * offer a command the CLI rejects. + */ +function parseFrontmatterBoolean(value: unknown): boolean | undefined { + if (typeof value === "boolean") return value; + if (typeof value === "number") { + return value === 1 ? true : value === 0 ? false : undefined; + } + if (typeof value !== "string") return undefined; + switch (value.trim().toLowerCase()) { + case "true": + case "yes": + case "on": + case "y": + return true; + case "false": + case "no": + case "off": + case "n": + return false; + default: + return undefined; + } +} function parseSkillFrontmatter(contents: string): SkillFrontmatter { const match = FRONTMATTER_PATTERN.exec(contents); @@ -47,15 +86,187 @@ function parseSkillFrontmatter(contents: string): SkillFrontmatter { } const record = parsed as Record; - const name = typeof record.name === "string" ? record.name.trim() : ""; const description = typeof record.description === "string" ? record.description.trim() : ""; return { kind: "parsed", - ...(name ? { name } : {}), ...(description ? { description } : {}), + ...(parseFrontmatterBoolean(record["disable-model-invocation"]) === true + ? { userInvocationOnly: true } + : {}), + ...(parseFrontmatterBoolean(record["user-invocable"]) === false + ? { userInvocable: false } + : {}), }; } +/** + * Where an administrator installs the policy file whose settings outrank every + * user and project one. Absent on almost every machine, which is why a missing + * file is the normal case rather than an error. + */ +export function claudeManagedSettingsPath( + path: Path.Path, + platform: NodeJS.Platform, + environment: NodeJS.ProcessEnv, +): string | undefined { + if (platform === "darwin") { + return "/Library/Application Support/ClaudeCode/managed-settings.json"; + } + if (platform === "win32") { + const programData = environment.PROGRAMDATA?.trim(); + return programData ? path.join(programData, "ClaudeCode", "managed-settings.json") : undefined; + } + return "/etc/claude-code/managed-settings.json"; +} + +/** + * Settings files Claude Code merges for `skillOverrides`, in increasing + * precedence: user, project, project-local, then the administrator's managed + * policy, which wins outright. When the workspace sits inside a git + * repository, the repository root's `settings.local.json` is read too and + * outranks the workspace's own local file. Verified against the CLI from a + * nested cwd: a root local file switching a skill off wins over a cwd one + * switching it on, the root's plain `settings.json` is not consulted, and + * without a `.git` above the cwd no root file is read. A skill the user + * switched off is reported disabled rather than dropped, so the picker can + * grey it out instead of silently losing it. + */ +export function skillOverrideSettingsPaths( + path: Path.Path, + configDirPath: string, + cwd: string | undefined, + platform: NodeJS.Platform, + environment: NodeJS.ProcessEnv, + repositoryRoot?: string, +): ReadonlyArray { + const managedPath = claudeManagedSettingsPath(path, platform, environment); + const root = repositoryRoot !== undefined && repositoryRoot !== cwd ? repositoryRoot : undefined; + return [ + path.join(configDirPath, "settings.json"), + ...(cwd + ? [ + path.join(cwd, ".claude", "settings.json"), + path.join(cwd, ".claude", "settings.local.json"), + ] + : []), + ...(root ? [path.join(root, ".claude", "settings.local.json")] : []), + ...(managedPath ? [managedPath] : []), + ]; +} + +/** + * Nearest ancestor of `cwd` (inclusive) holding a `.git` entry, which is the + * boundary Claude Code walks up to for project settings. `undefined` outside + * a repository. + */ +const findRepositoryRoot = Effect.fn("findRepositoryRoot")(function* ( + cwd: string, +): Effect.fn.Return { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + let current = path.resolve(cwd); + while (true) { + const isRoot = yield* fileSystem + .exists(path.join(current, ".git")) + .pipe(Effect.orElseSucceed(() => false)); + if (isRoot) { + return current; + } + const parent = path.dirname(current); + if (parent === current) { + return undefined; + } + current = parent; + } +}); + +/** + * The four states Claude Code accepts. The CLI validates the whole map, not + * each entry: verified against it, one entry with an unknown value (or a + * boolean) makes it drop every override in that file, so this schema does the + * same rather than applying the valid siblings the CLI ignores. + */ +const SkillOverrideValue = Schema.Literals(["on", "name-only", "user-invocable-only", "off"]); + +// Lenient because these settings files are hand-edited and Claude Code itself +// tolerates comments and trailing commas in them. +const SkillOverrideSettings = fromLenientJson( + Schema.Struct({ + skillOverrides: Schema.optional(Schema.Record(Schema.String, SkillOverrideValue)), + }), +); +const decodeSkillOverrideSettings = Schema.decodeUnknownEffect(SkillOverrideSettings); + +/** + * What a `skillOverrides` entry says about one skill. `"user-invocable-only"` + * hides it from the agent exactly as `disable-model-invocation` does, so it is + * kept apart from a plain on/off decision rather than collapsed into one. + */ +type SkillOverride = { + readonly enabled: boolean; + readonly userInvocationOnly: boolean; +}; + +function parseSkillOverride(value: typeof SkillOverrideValue.Type): SkillOverride { + switch (value) { + case "off": + return { enabled: false, userInvocationOnly: false }; + case "user-invocable-only": + return { enabled: true, userInvocationOnly: true }; + case "on": + case "name-only": + return { enabled: true, userInvocationOnly: false }; + } +} + +const readSkillOverrides = Effect.fn("readSkillOverrides")(function* ( + configDirPath: string, + cwd: string | undefined, + environment: NodeJS.ProcessEnv, +): Effect.fn.Return, never, FileSystem.FileSystem | Path.Path> { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const platform = yield* HostProcessPlatform; + const overridesByName = new Map(); + const repositoryRoot = cwd === undefined ? undefined : yield* findRepositoryRoot(cwd); + + for (const settingsPath of skillOverrideSettingsPaths( + path, + configDirPath, + cwd, + platform, + environment, + repositoryRoot, + )) { + const contents = yield* fileSystem + .readFileString(settingsPath) + .pipe(Effect.orElseSucceed(() => undefined)); + if (contents === undefined) { + continue; + } + + const parsed = yield* decodeSkillOverrideSettings(contents).pipe( + Effect.tapError((cause) => + Effect.logDebug("claude settings file is unreadable; ignoring skillOverrides", { + path: settingsPath, + cause, + }), + ), + Effect.orElseSucceed(() => undefined), + ); + const overrides = parsed?.skillOverrides; + if (!overrides) { + continue; + } + + for (const [name, value] of Object.entries(overrides)) { + overridesByName.set(name, parseSkillOverride(value)); + } + } + + return overridesByName; +}); + /** * Resolve the Claude config directory the CLI would use, matching the * precedence the spawned CLI sees: the instance's `homePath` (exported as @@ -85,12 +296,14 @@ const resolveClaudeConfigDirPath = Effect.fn("resolveClaudeConfigDirPath")(funct }); /** - * Enumerate Claude Code skills from the user config dir, workspace - * `.agents/skills`, and workspace `.claude/skills`, in that order. Discovery - * is best-effort: unreadable roots and malformed skill entries are skipped so - * a broken skill never degrades the provider snapshot. On name collisions, - * later roots win: `.agents` beats user and `.claude` beats `.agents`, matching - * Claude Code's resolution. + * Enumerate Claude Code skills from the user config dir and the workspace + * `.claude/skills`. Discovery is best-effort: unreadable roots and malformed + * skill entries are skipped so a broken skill never degrades the provider + * snapshot. Roots are listed highest precedence first and the first hit for a + * name wins, matching Claude Code: verified against the CLI with the same + * skill name in both scopes, the user copy is the one that runs. Reporting the + * project copy instead would attach its invocation metadata to a command + * Claude Code resolves elsewhere. */ export const discoverClaudeSkills = Effect.fn("discoverClaudeSkills")(function* ( config: Pick, @@ -100,15 +313,11 @@ export const discoverClaudeSkills = Effect.fn("discoverClaudeSkills")(function* const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const configDirPath = yield* resolveClaudeConfigDirPath(config, environment ?? process.env, cwd); + const skillOverrides = yield* readSkillOverrides(configDirPath, cwd, environment ?? process.env); const roots: ReadonlyArray<{ directory: string; scope: ClaudeSkillScope }> = [ { directory: path.join(configDirPath, "skills"), scope: "user" }, - ...(cwd - ? [ - { directory: path.join(cwd, ".agents", "skills"), scope: "project" as const }, - { directory: path.join(cwd, ".claude", "skills"), scope: "project" as const }, - ] - : []), + ...(cwd ? [{ directory: path.join(cwd, ".claude", "skills"), scope: "project" as const }] : []), ]; const skillsByName = new Map(); @@ -134,19 +343,39 @@ export const discoverClaudeSkills = Effect.fn("discoverClaudeSkills")(function* continue; } - const name = (frontmatter.kind === "parsed" ? frontmatter.name : undefined) ?? entry.trim(); + // Claude Code identifies a skill by its directory, not by the + // frontmatter `name`: verified against the CLI, a skill in `probe-alias/` + // declaring `name: probe-alias-frontmatter` is published as + // `probe-alias`, and only `skillOverrides["probe-alias"]` switches it + // off. Keying off the frontmatter name would report a command that does + // not exist and miss the override that disables it. + const name = entry.trim(); if (!name) { continue; } + // First root wins, so a later root never displaces a higher-precedence + // skill of the same name. + if (skillsByName.has(name)) { + continue; + } + + const override = skillOverrides.get(name); + const userInvocationOnly = + (frontmatter.kind === "parsed" && frontmatter.userInvocationOnly === true) || + override?.userInvocationOnly === true; skillsByName.set(name, { name, path: skillPath, - enabled: true, + enabled: override?.enabled ?? true, scope: root.scope, ...(frontmatter.kind === "parsed" && frontmatter.description ? { description: frontmatter.description } : {}), + ...(userInvocationOnly ? { userInvocationOnly: true } : {}), + ...(frontmatter.kind === "parsed" && frontmatter.userInvocable === false + ? { userInvocable: false } + : {}), }); } } diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index 15d7a1ff0216..6bb14321a722 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -21,7 +21,7 @@ * * @module provider/Drivers/CodexDriver */ -import { CodexSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; +import { CodexSettings, ProviderDriverKind } from "@t3tools/contracts"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -39,8 +39,9 @@ import { makeCodexAdapter } from "../Layers/CodexAdapter.ts"; import { checkCodexProviderStatus, makePendingCodexProvider } from "../Layers/CodexProvider.ts"; import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; +import * as ModelManifest from "../ModelManifest.ts"; import type { ProviderDriver, ProviderInstance } from "../ProviderDriver.ts"; -import type { ServerProviderDraft } from "../providerSnapshot.ts"; +import { withInstanceIdentity } from "./instanceIdentity.ts"; import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; import { enrichProviderSnapshotWithVersionAdvisory, @@ -78,33 +79,12 @@ export type CodexDriverEnv = | Crypto.Crypto | FileSystem.FileSystem | HttpClient.HttpClient + | ModelManifest.ModelManifest | Path.Path | ProviderEventLoggers | ServerConfig | ServerSettingsService; -/** - * Stamp instance identity onto a `ServerProvider` snapshot produced by the - * driver-kind-only codex helpers. Once `buildServerProvider` in - * `providerSnapshot.ts` is widened to accept `instanceId`/`driver`, this - * wrapper disappears. - */ -const withInstanceIdentity = - (input: { - readonly instanceId: ProviderInstance["instanceId"]; - readonly displayName: string | undefined; - readonly accentColor: string | undefined; - readonly continuationGroupKey: string; - }) => - (snapshot: ServerProviderDraft): ServerProvider => ({ - ...snapshot, - instanceId: input.instanceId, - driver: DRIVER_KIND, - ...(input.displayName ? { displayName: input.displayName } : {}), - ...(input.accentColor ? { accentColor: input.accentColor } : {}), - continuation: { groupKey: input.continuationGroupKey }, - }); - export const CodexDriver: ProviderDriver = { driverKind: DRIVER_KIND, metadata: { @@ -119,11 +99,13 @@ export const CodexDriver: ProviderDriver = { const httpClient = yield* HttpClient.HttpClient; const serverSettings = yield* ServerSettingsService; const eventLoggers = yield* ProviderEventLoggers; + const modelManifest = yield* ModelManifest.ModelManifest; const processEnv = mergeProviderInstanceEnvironment(environment); const homeLayout = yield* resolveCodexHomeLayout(config); const continuationIdentity = codexContinuationIdentity(homeLayout); const stampIdentity = withInstanceIdentity({ instanceId, + driverKind: DRIVER_KIND, displayName, accentColor, continuationGroupKey: continuationIdentity.continuationKey, @@ -166,8 +148,19 @@ export const CodexDriver: ProviderDriver = { // in as instance rebuilds from the registry rather than in-place // updates. Pre-provide `ChildProcessSpawner` so the check fits // `makeManagedServerProvider.checkProvider`'s `R = never`. - const checkProvider = checkCodexProviderStatus(effectiveConfig, undefined, processEnv).pipe( - Effect.map(stampIdentity), + // Kick the TTL-gated manifest refresh in the background and classify + // with the in-memory manifest, so a slow or hung fetch never delays the + // provider check. A refresh that lands mid-probe applies on the next one. + const checkProvider = modelManifest.refreshInBackground.pipe( + Effect.andThen( + Effect.zipWith( + checkCodexProviderStatus(effectiveConfig, undefined, processEnv), + modelManifest.current, + (draft, manifest) => + stampIdentity(ModelManifest.applyModelManifest(draft, manifest, DRIVER_KIND)), + { concurrent: true }, + ), + ), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), ); const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings); @@ -177,7 +170,12 @@ export const CodexDriver: ProviderDriver = { streamSettings: snapshotSettings.streamSettings, haveSettingsChanged: haveProviderSnapshotSettingsChanged, initialSnapshot: (settings) => - makePendingCodexProvider(settings.provider).pipe(Effect.map(stampIdentity)), + Effect.zipWith( + makePendingCodexProvider(settings.provider), + modelManifest.current, + (draft, manifest) => + stampIdentity(ModelManifest.applyModelManifest(draft, manifest, DRIVER_KIND)), + ), checkProvider, enrichSnapshot: ({ settings, snapshot, publishSnapshot }) => enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenanceCapabilities, { diff --git a/apps/server/src/provider/Drivers/CursorDriver.ts b/apps/server/src/provider/Drivers/CursorDriver.ts index 2101664d5cb1..1187b6b03505 100644 --- a/apps/server/src/provider/Drivers/CursorDriver.ts +++ b/apps/server/src/provider/Drivers/CursorDriver.ts @@ -11,7 +11,7 @@ * * @module provider/Drivers/CursorDriver */ -import { CursorSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; +import { CursorSettings, ProviderDriverKind } from "@t3tools/contracts"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -38,7 +38,7 @@ import { type ProviderDriver, type ProviderInstance, } from "../ProviderDriver.ts"; -import type { ServerProviderDraft } from "../providerSnapshot.ts"; +import { withInstanceIdentity } from "./instanceIdentity.ts"; import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; import { makeProviderMaintenanceCapabilities, @@ -75,22 +75,6 @@ export type CursorDriverEnv = | ServerConfig | ServerSettingsService; -const withInstanceIdentity = - (input: { - readonly instanceId: ProviderInstance["instanceId"]; - readonly displayName: string | undefined; - readonly accentColor: string | undefined; - readonly continuationGroupKey: string; - }) => - (snapshot: ServerProviderDraft): ServerProvider => ({ - ...snapshot, - instanceId: input.instanceId, - driver: DRIVER_KIND, - ...(input.displayName ? { displayName: input.displayName } : {}), - ...(input.accentColor ? { accentColor: input.accentColor } : {}), - continuation: { groupKey: input.continuationGroupKey }, - }); - export const CursorDriver: ProviderDriver = { driverKind: DRIVER_KIND, metadata: { @@ -115,6 +99,7 @@ export const CursorDriver: ProviderDriver = { }); const stampIdentity = withInstanceIdentity({ instanceId, + driverKind: DRIVER_KIND, displayName, accentColor, continuationGroupKey: continuationIdentity.continuationKey, diff --git a/apps/server/src/provider/Drivers/GrokDriver.ts b/apps/server/src/provider/Drivers/GrokDriver.ts index 112f11013161..32d6149c3b50 100644 --- a/apps/server/src/provider/Drivers/GrokDriver.ts +++ b/apps/server/src/provider/Drivers/GrokDriver.ts @@ -1,4 +1,4 @@ -import { GrokSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; +import { GrokSettings, ProviderDriverKind } from "@t3tools/contracts"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -25,7 +25,7 @@ import { type ProviderDriver, type ProviderInstance, } from "../ProviderDriver.ts"; -import type { ServerProviderDraft } from "../providerSnapshot.ts"; +import { withInstanceIdentity } from "./instanceIdentity.ts"; import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; import { makeManualOnlyProviderMaintenanceCapabilities, @@ -58,22 +58,6 @@ export type GrokDriverEnv = | ServerConfig | ServerSettingsService; -const withInstanceIdentity = - (input: { - readonly instanceId: ProviderInstance["instanceId"]; - readonly displayName: string | undefined; - readonly accentColor: string | undefined; - readonly continuationGroupKey: string; - }) => - (snapshot: ServerProviderDraft): ServerProvider => ({ - ...snapshot, - instanceId: input.instanceId, - driver: DRIVER_KIND, - ...(input.displayName ? { displayName: input.displayName } : {}), - ...(input.accentColor ? { accentColor: input.accentColor } : {}), - continuation: { groupKey: input.continuationGroupKey }, - }); - export const GrokDriver: ProviderDriver = { driverKind: DRIVER_KIND, metadata: { @@ -88,6 +72,7 @@ export const GrokDriver: ProviderDriver = { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const httpClient = yield* HttpClient.HttpClient; const serverSettings = yield* ServerSettingsService; + const { cwd } = yield* ServerConfig; const eventLoggers = yield* ProviderEventLoggers; const processEnv = mergeProviderInstanceEnvironment(environment); const continuationIdentity = defaultProviderContinuationIdentity({ @@ -96,6 +81,7 @@ export const GrokDriver: ProviderDriver = { }); const stampIdentity = withInstanceIdentity({ instanceId, + driverKind: DRIVER_KIND, displayName, accentColor, continuationGroupKey: continuationIdentity.continuationKey, @@ -113,7 +99,7 @@ export const GrokDriver: ProviderDriver = { }); const textGeneration = yield* makeGrokTextGeneration(effectiveConfig, processEnv); - const checkProvider = checkGrokProviderStatus(effectiveConfig, processEnv).pipe( + const checkProvider = checkGrokProviderStatus(effectiveConfig, processEnv, cwd).pipe( Effect.map(stampIdentity), Effect.provideService(Crypto.Crypto, crypto), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), diff --git a/apps/server/src/provider/Drivers/GrokSkills.test.ts b/apps/server/src/provider/Drivers/GrokSkills.test.ts new file mode 100644 index 000000000000..3536a37a9920 --- /dev/null +++ b/apps/server/src/provider/Drivers/GrokSkills.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { discoverGrokSkills, parseGrokInspectSkills } from "./GrokSkills.ts"; + +const inspectPayload = (skills: ReadonlyArray) => JSON.stringify({ skills }); + +describe("parseGrokInspectSkills", () => { + it("maps inspect entries onto provider skills, sorted by name", () => { + const skills = parseGrokInspectSkills( + inspectPayload([ + { + name: "writing-docs", + description: "Write user docs.", + source: { type: "user", path: "/home/dev/.grok/skills/writing-docs/SKILL.md" }, + userInvocable: true, + }, + { + name: "deploy", + description: "Deploy the app.", + source: { + type: "plugin", + path: "/home/dev/.grok/installed-plugins/pkg/plug/skills/deploy/SKILL.md", + }, + userInvocable: true, + }, + ]), + ); + + expect(skills).toEqual([ + { + name: "deploy", + description: "Deploy the app.", + path: "/home/dev/.grok/installed-plugins/pkg/plug/skills/deploy/SKILL.md", + scope: "plugin", + enabled: true, + }, + { + name: "writing-docs", + description: "Write user docs.", + path: "/home/dev/.grok/skills/writing-docs/SKILL.md", + scope: "user", + enabled: true, + }, + ]); + }); + + it("disables skills the CLI marks as not user-invocable", () => { + const skills = parseGrokInspectSkills( + inspectPayload([ + { + name: "internal-helper", + source: { type: "bundled", path: "/opt/grok/bundled/skills/internal-helper/SKILL.md" }, + userInvocable: false, + }, + ]), + ); + + expect(skills).toEqual([ + { + name: "internal-helper", + path: "/opt/grok/bundled/skills/internal-helper/SKILL.md", + scope: "bundled", + enabled: false, + }, + ]); + }); + + it("skips entries without a name or a filesystem path", () => { + const skills = parseGrokInspectSkills( + inspectPayload([ + { name: " ", source: { type: "user", path: "/tmp/skills/a/SKILL.md" } }, + { name: "no-path", source: { type: "user" } }, + { name: "no-source" }, + "not-an-object", + { name: "kept", source: { type: "project", path: "/repo/.grok/skills/kept/SKILL.md" } }, + ]), + ); + + expect(skills.map((skill) => skill.name)).toEqual(["kept"]); + }); + + it("returns an empty list for malformed or unexpected output", () => { + expect(parseGrokInspectSkills("not json")).toEqual([]); + expect(parseGrokInspectSkills("null")).toEqual([]); + expect(parseGrokInspectSkills(JSON.stringify({ skills: "nope" }))).toEqual([]); + expect(parseGrokInspectSkills(JSON.stringify({}))).toEqual([]); + }); +}); + +describe("discoverGrokSkills", () => { + it.effect("spawns the inspect probe in the configured cwd", () => { + const spawnCwds: Array = []; + const spawner = ChildProcessSpawner.make((command) => { + spawnCwds.push(command._tag === "StandardCommand" ? command.options.cwd : undefined); + return Effect.succeed( + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.encodeText( + Stream.make( + inspectPayload([ + { + name: "kept", + source: { type: "project", path: "/workspaces/demo/.grok/skills/kept/SKILL.md" }, + }, + ]), + ), + ), + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }), + ); + }); + + return Effect.gen(function* () { + const skills = yield* discoverGrokSkills({ binaryPath: "grok" }, {}, "/workspaces/demo").pipe( + Effect.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner)), + ); + + expect(spawnCwds).toEqual(["/workspaces/demo"]); + expect(skills.map((skill) => skill.name)).toEqual(["kept"]); + }); + }); +}); diff --git a/apps/server/src/provider/Drivers/GrokSkills.ts b/apps/server/src/provider/Drivers/GrokSkills.ts new file mode 100644 index 000000000000..a7c2c2ae3028 --- /dev/null +++ b/apps/server/src/provider/Drivers/GrokSkills.ts @@ -0,0 +1,119 @@ +/** + * GrokSkills — skill discovery for the `$` picker via `grok inspect --json`. + * + * Unlike Claude Code, the Grok CLI reports its full skill catalog itself: + * `grok inspect --json` returns `skills[]` with `name`, `description`, + * `source.type` (`user` / `project` / `bundled` / `plugin`), `source.path` + * (the absolute `SKILL.md` path), and `userInvocable`. Asking the CLI beats + * scanning the filesystem because the catalog honors Grok's own skill config + * (ignore lists, disabled skills) and includes plugin skills, which live + * three levels deep under `~/.grok/installed-plugins/` where a flat scan + * cannot see them. This mirrors how the Codex app-server reports skills over + * `skills/list`. Discovery is best-effort: an older CLI without `inspect`, + * a timeout, or malformed output yields an empty list, never a degraded + * provider snapshot. + * + * @module provider/Drivers/GrokSkills + */ +import type { GrokSettings, ServerProviderSkill } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Result from "effect/Result"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; + +import { spawnAndCollect } from "../providerSnapshot.ts"; + +const GROK_SKILLS_PROBE_TIMEOUT_MS = 4_000; + +/** + * Map `grok inspect --json` output onto provider skills. Entries without a + * name or a filesystem path are skipped; `userInvocable: false` skills are + * kept but disabled so pickers that filter on `enabled` hide them. + */ +export function parseGrokInspectSkills(stdout: string): ReadonlyArray { + let parsed: unknown; + try { + parsed = JSON.parse(stdout); + } catch { + return []; + } + if (typeof parsed !== "object" || parsed === null) { + return []; + } + const entries = (parsed as Record).skills; + if (!Array.isArray(entries)) { + return []; + } + + const skillsByName = new Map(); + for (const entry of entries) { + if (typeof entry !== "object" || entry === null) { + continue; + } + const record = entry as Record; + const name = typeof record.name === "string" ? record.name.trim() : ""; + const source = + typeof record.source === "object" && record.source !== null + ? (record.source as Record) + : undefined; + const path = typeof source?.path === "string" ? source.path.trim() : ""; + if (!name || !path) { + continue; + } + const scope = typeof source?.type === "string" ? source.type.trim() : ""; + const description = typeof record.description === "string" ? record.description.trim() : ""; + skillsByName.set(name, { + name, + path, + enabled: record.userInvocable !== false, + ...(scope ? { scope } : {}), + ...(description ? { description } : {}), + }); + } + + return [...skillsByName.values()].sort((left, right) => left.name.localeCompare(right.name)); +} + +/** + * Run `grok inspect --json` and map the reported catalog onto provider + * skills. Never fails: any spawn error, non-zero exit, or timeout resolves + * to an empty list. + */ +export const discoverGrokSkills = Effect.fn("discoverGrokSkills")(function* ( + grokSettings: Pick, + environment: NodeJS.ProcessEnv = process.env, + cwd?: string, +): Effect.fn.Return< + ReadonlyArray, + never, + ChildProcessSpawner.ChildProcessSpawner +> { + const command = grokSettings.binaryPath || "grok"; + const inspectResult = yield* Effect.gen(function* () { + const spawnCommand = yield* resolveSpawnCommand(command, ["inspect", "--json"], { + env: environment, + }); + return yield* spawnAndCollect( + command, + ChildProcess.make(spawnCommand.command, spawnCommand.args, { + ...(cwd ? { cwd } : {}), + env: environment, + shell: spawnCommand.shell, + }), + ); + }).pipe(Effect.timeoutOption(GROK_SKILLS_PROBE_TIMEOUT_MS), Effect.result); + + if (Result.isFailure(inspectResult) || Option.isNone(inspectResult.success)) { + yield* Effect.logDebug("Grok skill discovery failed; continuing without skills."); + return []; + } + const output = inspectResult.success.value; + if (output.code !== 0) { + yield* Effect.logDebug("Grok skill discovery exited non-zero; continuing without skills.", { + exitCode: output.code, + }); + return []; + } + return parseGrokInspectSkills(output.stdout); +}); diff --git a/apps/server/src/provider/Drivers/OpenCodeDriver.ts b/apps/server/src/provider/Drivers/OpenCodeDriver.ts index a01e414f8116..4ba0b858c625 100644 --- a/apps/server/src/provider/Drivers/OpenCodeDriver.ts +++ b/apps/server/src/provider/Drivers/OpenCodeDriver.ts @@ -12,7 +12,7 @@ * * @module provider/Drivers/OpenCodeDriver */ -import { OpenCodeSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; +import { OpenCodeSettings, ProviderDriverKind } from "@t3tools/contracts"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -34,12 +34,13 @@ import { import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; import { OpenCodeRuntime } from "../opencodeRuntime.ts"; +import * as OpenCodeServerOwner from "../OpenCodeServerOwner.ts"; import { defaultProviderContinuationIdentity, type ProviderDriver, type ProviderInstance, } from "../ProviderDriver.ts"; -import type { ServerProviderDraft } from "../providerSnapshot.ts"; +import { withInstanceIdentity } from "./instanceIdentity.ts"; import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; import { enrichProviderSnapshotWithVersionAdvisory, @@ -88,22 +89,6 @@ export type OpenCodeDriverEnv = | ServerConfig | ServerSettingsService; -const withInstanceIdentity = - (input: { - readonly instanceId: ProviderInstance["instanceId"]; - readonly displayName: string | undefined; - readonly accentColor: string | undefined; - readonly continuationGroupKey: string; - }) => - (snapshot: ServerProviderDraft): ServerProvider => ({ - ...snapshot, - instanceId: input.instanceId, - driver: DRIVER_KIND, - ...(input.displayName ? { displayName: input.displayName } : {}), - ...(input.accentColor ? { accentColor: input.accentColor } : {}), - continuation: { groupKey: input.continuationGroupKey }, - }); - export const OpenCodeDriver: ProviderDriver = { driverKind: DRIVER_KIND, metadata: { @@ -126,6 +111,7 @@ export const OpenCodeDriver: ProviderDriver }); const stampIdentity = withInstanceIdentity({ instanceId, + driverKind: DRIVER_KIND, displayName, accentColor, continuationGroupKey: continuationIdentity.continuationKey, @@ -141,13 +127,27 @@ export const OpenCodeDriver: ProviderDriver environment: processEnv, ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), }); - const textGeneration = yield* makeOpenCodeTextGeneration(effectiveConfig, processEnv); + const serverOwner = yield* OpenCodeServerOwner.make({ + binaryPath: effectiveConfig.binaryPath, + directory: serverConfig.cwd, + ...(effectiveConfig.serverPassword + ? { serverPassword: effectiveConfig.serverPassword } + : {}), + environment: processEnv, + }); + const textGeneration = yield* makeOpenCodeTextGeneration(effectiveConfig).pipe( + Effect.provideService(OpenCodeServerOwner.OpenCodeServerOwner, serverOwner), + ); const checkProvider = checkOpenCodeProviderStatus( effectiveConfig, serverConfig.cwd, processEnv, - ).pipe(Effect.map(stampIdentity), Effect.provideService(OpenCodeRuntime, openCodeRuntime)); + ).pipe( + Effect.map(stampIdentity), + Effect.provideService(OpenCodeServerOwner.OpenCodeServerOwner, serverOwner), + Effect.provideService(OpenCodeRuntime, openCodeRuntime), + ); const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings); const snapshot = yield* makeManagedServerProvider>( @@ -156,6 +156,8 @@ export const OpenCodeDriver: ProviderDriver getSettings: snapshotSettings.getSettings, streamSettings: snapshotSettings.streamSettings, haveSettingsChanged: haveProviderSnapshotSettingsChanged, + checkProviderOnSettingsChange: () => false, + refreshOnInterval: false, initialSnapshot: (settings) => makePendingOpenCodeProvider(settings.provider).pipe(Effect.map(stampIdentity)), checkProvider, diff --git a/apps/server/src/provider/Drivers/instanceIdentity.ts b/apps/server/src/provider/Drivers/instanceIdentity.ts new file mode 100644 index 000000000000..2fbc1c4a9f08 --- /dev/null +++ b/apps/server/src/provider/Drivers/instanceIdentity.ts @@ -0,0 +1,28 @@ +import type { ProviderDriverKind, ServerProvider } from "@t3tools/contracts"; + +import type { ProviderInstance } from "../ProviderDriver.ts"; +import type { ServerProviderDraft } from "../providerSnapshot.ts"; + +/** + * Stamp instance identity onto a `ServerProvider` snapshot produced by the + * driver-kind-only snapshot helpers. Every driver builds its snapshot without + * knowing its own instance, so it pipes the draft through this stamper before + * publishing. Once `buildServerProvider` in `providerSnapshot.ts` is widened to + * accept `instanceId`/`driver`, this wrapper disappears. + */ +export const withInstanceIdentity = + (input: { + readonly instanceId: ProviderInstance["instanceId"]; + readonly driverKind: ProviderDriverKind; + readonly displayName: string | undefined; + readonly accentColor: string | undefined; + readonly continuationGroupKey: string; + }) => + (snapshot: ServerProviderDraft): ServerProvider => ({ + ...snapshot, + instanceId: input.instanceId, + driver: input.driverKind, + ...(input.displayName ? { displayName: input.displayName } : {}), + ...(input.accentColor ? { accentColor: input.accentColor } : {}), + continuation: { groupKey: input.continuationGroupKey }, + }); diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index a45eae6faf3e..d629394acad7 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -35,6 +35,13 @@ import * as TestClock from "effect/testing/TestClock"; import { attachmentRelativePath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; +import { + SYNTHETIC_CLAUDE_CAPABLE_MODEL, + SYNTHETIC_CLAUDE_COLLIDING_ALIAS, + SYNTHETIC_CLAUDE_MODEL_CATALOG, + SYNTHETIC_CLAUDE_STANDARD_MODEL, + SYNTHETIC_CLAUDE_THINKING_MODEL, +} from "../ClaudeModelCatalog.testFixtures.ts"; import { ProviderAdapterProcessError, ProviderAdapterValidationError } from "../Errors.ts"; import type { ClaudeAdapterShape } from "../Services/ClaudeAdapter.ts"; import { makeClaudeAdapter, type ClaudeAdapterLiveOptions } from "./ClaudeAdapter.ts"; @@ -166,6 +173,7 @@ function makeHarness(config?: { const adapterOptions: ClaudeAdapterLiveOptions = { ...(config?.instanceId ? { instanceId: config.instanceId } : {}), + modelCatalog: Effect.succeed(SYNTHETIC_CLAUDE_MODEL_CATALOG), createQuery: (input) => { createInput = input; return query; @@ -265,6 +273,7 @@ async function readFirstPromptMessage( const THREAD_ID = ThreadId.make("thread-claude-1"); const RESUME_THREAD_ID = ThreadId.make("thread-claude-resume"); +const SYNTHETIC_SUBAGENT_MODEL = "claude-synthetic-subagent[expanded]"; describe("ClaudeAdapterLive", () => { it.effect("returns validation error for non-claude provider on startSession", () => { @@ -413,146 +422,26 @@ describe("ClaudeAdapterLive", () => { ); }); - it.effect("forwards claude effort levels into query options", () => { - const harness = makeHarness(); - return Effect.gen(function* () { - const adapter = yield* ClaudeAdapter; - yield* adapter.startSession({ - threadId: THREAD_ID, - provider: ProviderDriverKind.make("claudeAgent"), - modelSelection: createModelSelection( - ProviderInstanceId.make("claudeAgent"), - "claude-opus-4-6", - [{ id: "effort", value: "max" }], - ), - runtimeMode: "full-access", - }); - - const createInput = harness.getLastCreateQueryInput(); - assert.equal(createInput?.options.effort, "max"); - }).pipe( - Effect.provideService(Random.Random, makeDeterministicRandomService()), - Effect.provide(harness.layer), - ); - }); - - it.effect("runs Claude SDK sessions with the configured CLAUDE_CONFIG_DIR", () => { - const harness = makeHarness({ claudeConfig: { homePath: "~/.claude-work" } }); - return Effect.gen(function* () { - const adapter = yield* ClaudeAdapter; - yield* adapter.startSession({ - threadId: THREAD_ID, - provider: ProviderDriverKind.make("claudeAgent"), - modelSelection: createModelSelection( - ProviderInstanceId.make("claudeAgent"), - "claude-opus-4-6", - ), - runtimeMode: "full-access", - }); - - const createInput = harness.getLastCreateQueryInput(); - assert.equal( - createInput?.options.env?.CLAUDE_CONFIG_DIR, - NodePath.join(NodeOS.homedir(), ".claude-work"), - ); - }).pipe( - Effect.provideService(Random.Random, makeDeterministicRandomService()), - Effect.provide(harness.layer), - ); - }); - - it.effect("maps the Claude Opus 4.7 default effort to the SDK-supported max value", () => { - const harness = makeHarness(); + it.effect("passes the configured auto-compaction window to Claude", () => { + const harness = makeHarness({ claudeConfig: { autoCompactWindow: "300000" } }); return Effect.gen(function* () { const adapter = yield* ClaudeAdapter; yield* adapter.startSession({ threadId: THREAD_ID, provider: ProviderDriverKind.make("claudeAgent"), - modelSelection: { - instanceId: ProviderInstanceId.make("claudeAgent"), - model: "claude-opus-4-7", - }, runtimeMode: "full-access", }); - const createInput = harness.getLastCreateQueryInput(); - assert.equal(createInput?.options.effort, "max"); + const options = harness.getLastCreateQueryInput()?.options; + assert.deepEqual(options?.settings, { autoCompactWindow: 300000 }); + assert.deepEqual(options?.supportedDialogKinds, ["resume_return"]); }).pipe( Effect.provideService(Random.Random, makeDeterministicRandomService()), Effect.provide(harness.layer), ); }); - it.effect("maps xhigh effort for Claude Opus 4.7 to the SDK-supported max value", () => { - const harness = makeHarness(); - return Effect.gen(function* () { - const adapter = yield* ClaudeAdapter; - yield* adapter.startSession({ - threadId: THREAD_ID, - provider: ProviderDriverKind.make("claudeAgent"), - modelSelection: createModelSelection( - ProviderInstanceId.make("claudeAgent"), - "claude-opus-4-7", - [{ id: "effort", value: "xhigh" }], - ), - runtimeMode: "full-access", - }); - - const createInput = harness.getLastCreateQueryInput(); - assert.equal(createInput?.options.effort, "max"); - }).pipe( - Effect.provideService(Random.Random, makeDeterministicRandomService()), - Effect.provide(harness.layer), - ); - }); - - it.effect("preserves xhigh effort for Claude Fable 5", () => { - const harness = makeHarness(); - return Effect.gen(function* () { - const adapter = yield* ClaudeAdapter; - yield* adapter.startSession({ - threadId: THREAD_ID, - provider: ProviderDriverKind.make("claudeAgent"), - modelSelection: createModelSelection( - ProviderInstanceId.make("claudeAgent"), - "claude-fable-5", - [{ id: "effort", value: "xhigh" }], - ), - runtimeMode: "full-access", - }); - - const createInput = harness.getLastCreateQueryInput(); - assert.equal(createInput?.options.effort, "xhigh"); - }).pipe( - Effect.provideService(Random.Random, makeDeterministicRandomService()), - Effect.provide(harness.layer), - ); - }); - - it.effect("preserves xhigh effort for Claude Opus 5", () => { - const harness = makeHarness(); - return Effect.gen(function* () { - const adapter = yield* ClaudeAdapter; - yield* adapter.startSession({ - threadId: THREAD_ID, - provider: ProviderDriverKind.make("claudeAgent"), - modelSelection: createModelSelection( - ProviderInstanceId.make("claudeAgent"), - "claude-opus-5", - [{ id: "effort", value: "xhigh" }], - ), - runtimeMode: "full-access", - }); - - const createInput = harness.getLastCreateQueryInput(); - assert.equal(createInput?.options.effort, "xhigh"); - }).pipe( - Effect.provideService(Random.Random, makeDeterministicRandomService()), - Effect.provide(harness.layer), - ); - }); - - it.effect("falls back to default effort when unsupported max is requested for Sonnet 4.6", () => { + it.effect("forwards claude effort levels into query options", () => { const harness = makeHarness(); return Effect.gen(function* () { const adapter = yield* ClaudeAdapter; @@ -561,22 +450,22 @@ describe("ClaudeAdapterLive", () => { provider: ProviderDriverKind.make("claudeAgent"), modelSelection: createModelSelection( ProviderInstanceId.make("claudeAgent"), - "claude-sonnet-4-6", + SYNTHETIC_CLAUDE_CAPABLE_MODEL, [{ id: "effort", value: "max" }], ), runtimeMode: "full-access", }); const createInput = harness.getLastCreateQueryInput(); - assert.equal(createInput?.options.effort, "high"); + assert.equal(createInput?.options.effort, "max"); }).pipe( Effect.provideService(Random.Random, makeDeterministicRandomService()), Effect.provide(harness.layer), ); }); - it.effect("ignores adaptive effort for Haiku 4.5", () => { - const harness = makeHarness(); + it.effect("runs Claude SDK sessions with the configured CLAUDE_CONFIG_DIR", () => { + const harness = makeHarness({ claudeConfig: { homePath: "~/.claude-work" } }); return Effect.gen(function* () { const adapter = yield* ClaudeAdapter; yield* adapter.startSession({ @@ -584,21 +473,23 @@ describe("ClaudeAdapterLive", () => { provider: ProviderDriverKind.make("claudeAgent"), modelSelection: createModelSelection( ProviderInstanceId.make("claudeAgent"), - "claude-haiku-4-5", - [{ id: "effort", value: "high" }], + SYNTHETIC_CLAUDE_CAPABLE_MODEL, ), runtimeMode: "full-access", }); const createInput = harness.getLastCreateQueryInput(); - assert.equal(createInput?.options.effort, undefined); + assert.equal( + createInput?.options.env?.CLAUDE_CONFIG_DIR, + NodePath.join(NodeOS.homedir(), ".claude-work"), + ); }).pipe( Effect.provideService(Random.Random, makeDeterministicRandomService()), Effect.provide(harness.layer), ); }); - it.effect("forwards Claude thinking toggle into SDK settings for Haiku 4.5", () => { + it.effect("forwards Claude thinking toggle for models that support it", () => { const harness = makeHarness(); return Effect.gen(function* () { const adapter = yield* ClaudeAdapter; @@ -607,7 +498,7 @@ describe("ClaudeAdapterLive", () => { provider: ProviderDriverKind.make("claudeAgent"), modelSelection: createModelSelection( ProviderInstanceId.make("claudeAgent"), - "claude-haiku-4-5", + SYNTHETIC_CLAUDE_THINKING_MODEL, [{ id: "thinking", value: false }], ), runtimeMode: "full-access", @@ -623,7 +514,7 @@ describe("ClaudeAdapterLive", () => { ); }); - it.effect("ignores Claude thinking toggle for non-Haiku models", () => { + it.effect("ignores Claude thinking toggle for models without it", () => { const harness = makeHarness(); return Effect.gen(function* () { const adapter = yield* ClaudeAdapter; @@ -632,7 +523,7 @@ describe("ClaudeAdapterLive", () => { provider: ProviderDriverKind.make("claudeAgent"), modelSelection: createModelSelection( ProviderInstanceId.make("claudeAgent"), - "claude-sonnet-4-6", + SYNTHETIC_CLAUDE_STANDARD_MODEL, [{ id: "thinking", value: false }], ), runtimeMode: "full-access", @@ -655,7 +546,7 @@ describe("ClaudeAdapterLive", () => { provider: ProviderDriverKind.make("claudeAgent"), modelSelection: createModelSelection( ProviderInstanceId.make("claudeAgent"), - "claude-opus-4-6", + SYNTHETIC_CLAUDE_CAPABLE_MODEL, [{ id: "fastMode", value: true }], ), runtimeMode: "full-access", @@ -671,7 +562,7 @@ describe("ClaudeAdapterLive", () => { ); }); - it.effect("ignores claude fast mode for non-opus models", () => { + it.effect("ignores claude fast mode for models without it", () => { const harness = makeHarness(); return Effect.gen(function* () { const adapter = yield* ClaudeAdapter; @@ -680,7 +571,7 @@ describe("ClaudeAdapterLive", () => { provider: ProviderDriverKind.make("claudeAgent"), modelSelection: createModelSelection( ProviderInstanceId.make("claudeAgent"), - "claude-sonnet-4-6", + SYNTHETIC_CLAUDE_STANDARD_MODEL, [{ id: "fastMode", value: true }], ), runtimeMode: "full-access", @@ -694,6 +585,97 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect( + "keeps a configured custom alias opaque without disabling the canonical built-in", + () => { + const claudeConfig = { customModels: [SYNTHETIC_CLAUDE_COLLIDING_ALIAS] }; + const customHarness = makeHarness({ claudeConfig }); + const builtInHarness = makeHarness({ claudeConfig }); + const start = (harness: ReturnType, model: string) => + Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + modelSelection: createModelSelection(ProviderInstanceId.make("claudeAgent"), model, [ + { id: "effort", value: "max" }, + { id: "fastMode", value: true }, + { id: "contextWindow", value: "expanded" }, + ]), + runtimeMode: "full-access", + }); + return harness.getLastCreateQueryInput()!.options; + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + const runCustomFlow = Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + modelSelection: createModelSelection( + ProviderInstanceId.make("claudeAgent"), + SYNTHETIC_CLAUDE_COLLIDING_ALIAS, + [ + { id: "effort", value: "max" }, + { id: "fastMode", value: true }, + { id: "contextWindow", value: "expanded" }, + ], + ), + runtimeMode: "full-access", + }); + const options = customHarness.getLastCreateQueryInput()!.options; + + yield* adapter.sendTurn({ + threadId: THREAD_ID, + input: "use the built-in model", + modelSelection: createModelSelection( + ProviderInstanceId.make("claudeAgent"), + SYNTHETIC_CLAUDE_CAPABLE_MODEL, + [{ id: "contextWindow", value: "expanded" }], + ), + attachments: [], + }); + yield* Effect.promise(() => readFirstPromptText(customHarness.getLastCreateQueryInput())); + yield* adapter.sendTurn({ + threadId: THREAD_ID, + input: "keep this prompt literal", + modelSelection: createModelSelection( + ProviderInstanceId.make("claudeAgent"), + SYNTHETIC_CLAUDE_COLLIDING_ALIAS, + [{ id: "effort", value: "ultrathink" }], + ), + attachments: [], + }); + const prompt = yield* Effect.promise(() => + readFirstPromptText(customHarness.getLastCreateQueryInput()), + ); + return { options, prompt }; + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(customHarness.layer), + ); + + return Effect.gen(function* () { + const { options: customOptions, prompt: customPrompt } = yield* runCustomFlow; + assert.equal(customOptions.model, SYNTHETIC_CLAUDE_COLLIDING_ALIAS); + assert.equal(customOptions.effort, undefined); + assert.equal(customOptions.settings, undefined); + assert.deepEqual(customHarness.query.setModelCalls, [ + `${SYNTHETIC_CLAUDE_CAPABLE_MODEL}[expanded]`, + SYNTHETIC_CLAUDE_COLLIDING_ALIAS, + ]); + assert.equal(customPrompt, "keep this prompt literal"); + + const builtInOptions = yield* start(builtInHarness, SYNTHETIC_CLAUDE_CAPABLE_MODEL); + assert.equal(builtInOptions.model, `${SYNTHETIC_CLAUDE_CAPABLE_MODEL}[expanded]`); + assert.equal(builtInOptions.effort, "max"); + assert.deepEqual(builtInOptions.settings, { fastMode: true }); + }); + }, + ); + it.effect("treats ultrathink as a prompt keyword instead of a session effort", () => { const harness = makeHarness(); return Effect.gen(function* () { @@ -703,7 +685,7 @@ describe("ClaudeAdapterLive", () => { provider: ProviderDriverKind.make("claudeAgent"), modelSelection: createModelSelection( ProviderInstanceId.make("claudeAgent"), - "claude-sonnet-4-6", + SYNTHETIC_CLAUDE_STANDARD_MODEL, [{ id: "effort", value: "ultrathink" }], ), runtimeMode: "full-access", @@ -715,7 +697,7 @@ describe("ClaudeAdapterLive", () => { attachments: [], modelSelection: createModelSelection( ProviderInstanceId.make("claudeAgent"), - "claude-sonnet-4-6", + SYNTHETIC_CLAUDE_STANDARD_MODEL, [{ id: "effort", value: "ultrathink" }], ), }); @@ -730,6 +712,39 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("keeps compact commands intact when ultrathink is selected", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const modelSelection = createModelSelection( + ProviderInstanceId.make("claudeAgent"), + SYNTHETIC_CLAUDE_STANDARD_MODEL, + [{ id: "effort", value: "ultrathink" }], + ); + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + modelSelection, + runtimeMode: "full-access", + }); + + yield* adapter.sendTurn({ + threadId: session.threadId, + input: "/compact", + attachments: [], + modelSelection, + }); + + const promptText = yield* Effect.promise(() => + readFirstPromptText(harness.getLastCreateQueryInput()), + ); + assert.equal(promptText, "/compact"); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("embeds image attachments in Claude user messages", () => { const baseDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "claude-attachments-")); const harness = makeHarness({ @@ -756,7 +771,7 @@ describe("ClaudeAdapterLive", () => { mimeType: "image/png", sizeBytes: 4, }; - const attachmentPath = NodePath.join(attachmentsDir, attachmentRelativePath(attachment)); + const attachmentPath = NodePath.join(attachmentsDir, attachmentRelativePath(attachment)!); NodeFS.mkdirSync(NodePath.dirname(attachmentPath), { recursive: true }); NodeFS.writeFileSync(attachmentPath, Uint8Array.from([1, 2, 3, 4])); @@ -795,6 +810,138 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("dispatches a $skill mention as a trailing slash command block", () => { + // Claude Code only runs `/name` from the message's last text block, so a + // chip picked mid-prompt is moved there and the surrounding prose kept. + const homeDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "claude-skills-home-")); + NodeFS.mkdirSync(NodePath.join(homeDir, "skills", "implement"), { recursive: true }); + NodeFS.writeFileSync( + NodePath.join(homeDir, "skills", "implement", "SKILL.md"), + "---\ndescription: Implement the tickets.\n---\n# Body\n", + ); + const harness = makeHarness({ claudeConfig: { homePath: homeDir } }); + return Effect.gen(function* () { + yield* Effect.addFinalizer(() => + Effect.sync(() => NodeFS.rmSync(homeDir, { recursive: true, force: true })), + ); + const adapter = yield* ClaudeAdapter; + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + + yield* adapter.sendTurn({ + threadId: session.threadId, + input: "ok, now $implement all the tickets\nstart with auth", + attachments: [], + }); + + const promptMessage = yield* Effect.promise(() => + readFirstPromptMessage(harness.getLastCreateQueryInput()), + ); + assert.deepEqual(promptMessage?.message.content, [ + { type: "text", text: "ok, now" }, + { type: "text", text: "/implement all the tickets\nstart with auth" }, + ]); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("keeps the skill command block after image attachments", () => { + // A command block followed by an image is not expanded by the CLI; the + // image must come first. + const baseDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "claude-skill-image-")); + const homeDir = NodePath.join(baseDir, "claude-home"); + NodeFS.mkdirSync(NodePath.join(homeDir, "skills", "review"), { recursive: true }); + NodeFS.writeFileSync( + NodePath.join(homeDir, "skills", "review", "SKILL.md"), + "---\ndescription: Review.\n---\n# Body\n", + ); + const harness = makeHarness({ baseDir, claudeConfig: { homePath: homeDir } }); + return Effect.gen(function* () { + yield* Effect.addFinalizer(() => + Effect.sync(() => NodeFS.rmSync(baseDir, { recursive: true, force: true })), + ); + const adapter = yield* ClaudeAdapter; + const { attachmentsDir } = yield* ServerConfig; + const attachment = { + type: "image" as const, + id: "thread-claude-attachment-12345678-1234-1234-1234-123456789abc", + name: "diagram.png", + mimeType: "image/png", + sizeBytes: 4, + }; + const attachmentPath = NodePath.join(attachmentsDir, attachmentRelativePath(attachment)!); + NodeFS.mkdirSync(NodePath.dirname(attachmentPath), { recursive: true }); + NodeFS.writeFileSync(attachmentPath, Uint8Array.from([1, 2, 3, 4])); + + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ + threadId: session.threadId, + input: "$review this screenshot", + attachments: [attachment], + }); + + const promptMessage = yield* Effect.promise(() => + readFirstPromptMessage(harness.getLastCreateQueryInput()), + ); + assert.isDefined(promptMessage); + const blocks = promptMessage.message.content as Array<{ type: string; text?: string }>; + assert.deepEqual( + blocks.map((block) => (block.type === "text" ? block.text : block.type)), + ["image", "/review this screenshot"], + ); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("leaves a $ mention of an unknown or disabled skill as prose", () => { + const homeDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "claude-skills-off-")); + NodeFS.mkdirSync(NodePath.join(homeDir, "skills", "deploy"), { recursive: true }); + NodeFS.writeFileSync( + NodePath.join(homeDir, "skills", "deploy", "SKILL.md"), + "---\ndescription: Deploy.\n---\n# Body\n", + ); + NodeFS.writeFileSync( + NodePath.join(homeDir, "settings.json"), + JSON.stringify({ skillOverrides: { deploy: "off" } }), + ); + const harness = makeHarness({ claudeConfig: { homePath: homeDir } }); + return Effect.gen(function* () { + yield* Effect.addFinalizer(() => + Effect.sync(() => NodeFS.rmSync(homeDir, { recursive: true, force: true })), + ); + const adapter = yield* ClaudeAdapter; + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ + threadId: session.threadId, + input: "run $deploy and echo $HOME", + attachments: [], + }); + + const promptText = yield* Effect.promise(() => + readFirstPromptText(harness.getLastCreateQueryInput()), + ); + assert.equal(promptText, "run $deploy and echo $HOME"); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("maps Claude stream/runtime messages to canonical provider runtime events", () => { const harness = makeHarness(); return Effect.gen(function* () { @@ -810,7 +957,7 @@ describe("ClaudeAdapterLive", () => { provider: ProviderDriverKind.make("claudeAgent"), modelSelection: { instanceId: ProviderInstanceId.make("claudeAgent"), - model: "claude-sonnet-4-5", + model: SYNTHETIC_CLAUDE_STANDARD_MODEL, }, runtimeMode: "full-access", }); @@ -1739,92 +1886,209 @@ describe("ClaudeAdapterLive", () => { ); }); - it.effect("keeps a resumed replacement session during slow stop cleanup", () => { - const queries: FakeClaudeQuery[] = []; - let signalUsageStarted: () => void = () => undefined; - const usageStarted = new Promise((resolve) => { - signalUsageStarted = resolve; + it.effect("completes with result usage without querying current context usage", () => { + const harness = makeHarness(); + let getContextUsageCalls = 0; + Object.assign(harness.query, { + getContextUsage: async () => { + getContextUsageCalls += 1; + return { + totalTokens: 999, + maxTokens: 200000, + isAutoCompactEnabled: true, + }; + }, }); - const layer = Layer.effect( - ClaudeAdapter, - Effect.gen(function* () { - const claudeConfig = decodeClaudeSettings({}); - return yield* makeClaudeAdapter(claudeConfig, { - createQuery: () => { - const query = new FakeClaudeQuery(); - if (queries.length === 0) { - Object.assign(query, { - getContextUsage: async () => { - signalUsageStarted(); - return await new Promise(() => undefined); - }, - }); - } - queries.push(query); - return query; - }, - }); - }), - ).pipe( - Layer.provideMerge(ServerConfig.layerTest("/tmp/claude-adapter-test", "/tmp")), - Layer.provideMerge(ServerSettingsService.layerTest()), - Layer.provideMerge(NodeServices.layer), - ); - return Effect.gen(function* () { const adapter = yield* ClaudeAdapter; - const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 8).pipe( + const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 7).pipe( Stream.runCollect, Effect.forkChild, ); - const firstSession = yield* adapter.startSession({ + yield* adapter.startSession({ threadId: THREAD_ID, provider: ProviderDriverKind.make("claudeAgent"), runtimeMode: "full-access", }); yield* adapter.sendTurn({ - threadId: firstSession.threadId, + threadId: THREAD_ID, input: "hello", attachments: [], }); - const interruptFiber = yield* adapter - .interruptTurn(firstSession.threadId) - .pipe(Effect.forkChild); - yield* Effect.promise(() => usageStarted); - assert.equal(queries[0]?.closeCalls, 1); + harness.query.emit({ + type: "assistant", + session_id: "sdk-session-result-usage", + uuid: "assistant-result-usage-1", + parent_tool_use_id: null, + message: { + id: "assistant-message-result-usage-1", + role: "assistant", + content: [], + usage: { + input_tokens: 80, + output_tokens: 20, + }, + }, + } as unknown as SDKMessage); + harness.query.emit({ + type: "assistant", + session_id: "sdk-session-result-usage", + uuid: "assistant-result-usage-2", + parent_tool_use_id: null, + message: { + id: "assistant-message-result-usage-2", + role: "assistant", + content: [], + usage: { + input_tokens: 180, + output_tokens: 20, + }, + }, + } as unknown as SDKMessage); + harness.query.emit({ + type: "assistant", + session_id: "sdk-session-result-usage", + uuid: "assistant-result-usage-3", + parent_tool_use_id: null, + message: { + id: "assistant-message-result-usage-3", + role: "assistant", + content: [], + }, + } as unknown as SDKMessage); + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + duration_ms: 1234, + duration_api_ms: 1200, + num_turns: 1, + result: "done", + stop_reason: "end_turn", + session_id: "sdk-session-result-usage", + usage: { + input_tokens: 400, + output_tokens: 50, + }, + modelUsage: { + [SYNTHETIC_CLAUDE_CAPABLE_MODEL]: { + contextWindow: 200000, + maxOutputTokens: 64000, + }, + }, + } as unknown as SDKMessage); - const replacement = yield* adapter.startSession({ + const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); + assert.equal(getContextUsageCalls, 0); + const usageEvent = runtimeEvents.find((event) => event.type === "thread.token-usage.updated"); + assert.equal(usageEvent?.type, "thread.token-usage.updated"); + if (usageEvent?.type === "thread.token-usage.updated") { + assert.deepEqual(usageEvent.payload.usage, { + usedTokens: 200, + lastUsedTokens: 200, + totalProcessedTokens: 450, + inputTokens: 180, + outputTokens: 20, + maxTokens: 200000, + }); + } + assert.equal( + runtimeEvents.find((event) => event.type === "turn.completed")?.type, + "turn.completed", + ); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("preserves compacted usage when completion follows an older assistant frame", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 9).pipe( + Stream.runCollect, + Effect.forkChild, + ); + + yield* adapter.startSession({ threadId: THREAD_ID, provider: ProviderDriverKind.make("claudeAgent"), runtimeMode: "full-access", - resumeCursor: firstSession.resumeCursor, }); - yield* TestClock.adjust("1 second"); - yield* Fiber.join(interruptFiber); + yield* adapter.sendTurn({ + threadId: THREAD_ID, + input: "hello", + attachments: [], + }); + harness.query.emit({ + type: "assistant", + session_id: "sdk-session-compacted-usage", + uuid: "assistant-compacted-usage", + parent_tool_use_id: null, + message: { + id: "assistant-message-compacted-usage", + role: "assistant", + content: [], + usage: { + input_tokens: 180, + output_tokens: 20, + }, + }, + } as unknown as SDKMessage); + harness.query.emit({ + type: "system", + subtype: "compact_boundary", + compact_metadata: { + pre_tokens: 200, + post_tokens: 40, + }, + session_id: "sdk-session-compacted-usage", + uuid: "compact-boundary-usage", + } as unknown as SDKMessage); + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + duration_ms: 1234, + duration_api_ms: 1200, + num_turns: 2, + result: "done", + stop_reason: "end_turn", + session_id: "sdk-session-compacted-usage", + usage: { + input_tokens: 400, + output_tokens: 50, + }, + modelUsage: { + [SYNTHETIC_CLAUDE_CAPABLE_MODEL]: { + contextWindow: 200000, + maxOutputTokens: 64000, + }, + }, + } as unknown as SDKMessage); - const activeSessions = yield* adapter.listSessions(); const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); - assert.equal(queries.length, 2); - assert.equal(queries[1]?.closeCalls, 0); - assert.equal(activeSessions.length, 1); - assert.deepEqual(activeSessions[0]?.resumeCursor, replacement.resumeCursor); - assert.deepEqual( - runtimeEvents - .filter((event) => event.type.startsWith("session.")) - .map((event) => event.type), - [ - "session.started", - "session.configured", - "session.state.changed", - "session.started", - "session.configured", - "session.state.changed", - ], + const finalUsageEvent = runtimeEvents.findLast( + (event) => event.type === "thread.token-usage.updated", + ); + assert.equal(finalUsageEvent?.type, "thread.token-usage.updated"); + if (finalUsageEvent?.type === "thread.token-usage.updated") { + assert.deepEqual(finalUsageEvent.payload.usage, { + usedTokens: 40, + lastUsedTokens: 200, + totalProcessedTokens: 450, + maxTokens: 200000, + }); + } + assert.equal( + runtimeEvents.find((event) => event.type === "turn.completed")?.type, + "turn.completed", ); }).pipe( Effect.provideService(Random.Random, makeDeterministicRandomService()), - Effect.provide(layer), + Effect.provide(harness.layer), ); }); @@ -1933,7 +2197,7 @@ describe("ClaudeAdapterLive", () => { provider: ProviderDriverKind.make("claudeAgent"), modelSelection: createModelSelection( ProviderInstanceId.make("claudeAgent"), - "claude-opus-4-6", + SYNTHETIC_CLAUDE_CAPABLE_MODEL, [{ id: "effort", value: "max" }], ), runtimeMode: "full-access", @@ -1962,7 +2226,7 @@ describe("ClaudeAdapterLive", () => { type: "assistant", parent_tool_use_id: "toolu_agent_m", message: { - model: "claude-sonnet-5[1m]", + model: SYNTHETIC_SUBAGENT_MODEL, content: [], }, uuid: "subagent-snapshot-uuid", @@ -1982,13 +2246,13 @@ describe("ClaudeAdapterLive", () => { const started = taskEvents[0]; assert.equal(started?.type, "task.started"); if (started?.type === "task.started") { - assert.equal(started.payload.model, "claude-opus-4-6"); + assert.equal(started.payload.model, SYNTHETIC_CLAUDE_CAPABLE_MODEL); assert.equal(started.payload.effort, "max"); } const progress = taskEvents[1]; assert.equal(progress?.type, "task.progress"); if (progress?.type === "task.progress") { - assert.equal(progress.payload.model, "claude-sonnet-5[1m]"); + assert.equal(progress.payload.model, SYNTHETIC_SUBAGENT_MODEL); assert.equal(progress.payload.effort, "max"); } }).pipe( @@ -2014,7 +2278,7 @@ describe("ClaudeAdapterLive", () => { provider: ProviderDriverKind.make("claudeAgent"), modelSelection: createModelSelection( ProviderInstanceId.make("claudeAgent"), - "claude-opus-4-6", + SYNTHETIC_CLAUDE_CAPABLE_MODEL, [{ id: "effort", value: "max" }], ), runtimeMode: "full-access", @@ -2031,7 +2295,7 @@ describe("ClaudeAdapterLive", () => { type: "assistant", parent_tool_use_id: "toolu_agent_early", message: { - model: "claude-sonnet-5[1m]", + model: SYNTHETIC_SUBAGENT_MODEL, content: [], }, uuid: "early-snapshot-uuid", @@ -2061,13 +2325,13 @@ describe("ClaudeAdapterLive", () => { const started = taskEvents[0]; assert.equal(started?.type, "task.started"); if (started?.type === "task.started") { - assert.equal(started.payload.model, "claude-sonnet-5[1m]"); + assert.equal(started.payload.model, SYNTHETIC_SUBAGENT_MODEL); assert.equal(started.payload.effort, "max"); } const progress = taskEvents[1]; assert.equal(progress?.type, "task.progress"); if (progress?.type === "task.progress") { - assert.equal(progress.payload.model, "claude-sonnet-5[1m]"); + assert.equal(progress.payload.model, SYNTHETIC_SUBAGENT_MODEL); } }).pipe( Effect.provideService(Random.Random, makeDeterministicRandomService()), @@ -2692,7 +2956,7 @@ describe("ClaudeAdapterLive", () => { output_tokens: 679, }, modelUsage: { - "claude-opus-4-6": { + [SYNTHETIC_CLAUDE_CAPABLE_MODEL]: { contextWindow: 200000, maxOutputTokens: 64000, }, @@ -2756,7 +3020,7 @@ describe("ClaudeAdapterLive", () => { total_tokens: 535000, }, modelUsage: { - "claude-opus-4-6": { + [SYNTHETIC_CLAUDE_CAPABLE_MODEL]: { contextWindow: 200000, maxOutputTokens: 64000, }, @@ -2833,7 +3097,7 @@ describe("ClaudeAdapterLive", () => { total_tokens: 535000, }, modelUsage: { - "claude-opus-4-6": { + [SYNTHETIC_CLAUDE_CAPABLE_MODEL]: { contextWindow: 200000, maxOutputTokens: 64000, }, @@ -3877,7 +4141,7 @@ describe("ClaudeAdapterLive", () => { cwd: "/tmp/claude-adapter-test", tools: [], mcp_servers: [], - model: "claude-sonnet-4-5", + model: SYNTHETIC_CLAUDE_STANDARD_MODEL, permissionMode: "bypassPermissions", slash_commands: [], output_style: "default", @@ -4040,12 +4304,14 @@ describe("ClaudeAdapterLive", () => { input: "hello", modelSelection: { instanceId: ProviderInstanceId.make("claudeAgent"), - model: "claude-opus-4-6", + model: SYNTHETIC_CLAUDE_CAPABLE_MODEL, }, attachments: [], }); - assert.deepEqual(harness.query.setModelCalls, ["claude-opus-4-6[1m]"]); + assert.deepEqual(harness.query.setModelCalls, [ + `${SYNTHETIC_CLAUDE_CAPABLE_MODEL}[expanded]`, + ]); }).pipe( Effect.provideService(Random.Random, makeDeterministicRandomService()), Effect.provide(harness.layer), @@ -4088,7 +4354,7 @@ describe("ClaudeAdapterLive", () => { const adapter = yield* ClaudeAdapter; const modelSelection = { instanceId: ProviderInstanceId.make("claudeAgent"), - model: "claude-opus-4-6", + model: SYNTHETIC_CLAUDE_CAPABLE_MODEL, }; const session = yield* adapter.startSession({ @@ -4135,8 +4401,8 @@ describe("ClaudeAdapterLive", () => { input: "hello", modelSelection: createModelSelection( ProviderInstanceId.make("claudeAgent"), - "claude-opus-4-6", - [{ id: "contextWindow", value: "1m" }], + SYNTHETIC_CLAUDE_CAPABLE_MODEL, + [{ id: "contextWindow", value: "expanded" }], ), attachments: [], }); @@ -4145,13 +4411,16 @@ describe("ClaudeAdapterLive", () => { input: "hello again", modelSelection: createModelSelection( ProviderInstanceId.make("claudeAgent"), - "claude-opus-4-6", - [{ id: "contextWindow", value: "200k" }], + SYNTHETIC_CLAUDE_CAPABLE_MODEL, + [{ id: "contextWindow", value: "standard" }], ), attachments: [], }); - assert.deepEqual(harness.query.setModelCalls, ["claude-opus-4-6[1m]", "claude-opus-4-6"]); + assert.deepEqual(harness.query.setModelCalls, [ + `${SYNTHETIC_CLAUDE_CAPABLE_MODEL}[expanded]`, + SYNTHETIC_CLAUDE_CAPABLE_MODEL, + ]); }).pipe( Effect.provideService(Random.Random, makeDeterministicRandomService()), Effect.provide(harness.layer), @@ -4361,7 +4630,7 @@ describe("ClaudeAdapterLive", () => { uuid: "assistant-exit-plan", parent_tool_use_id: null, message: { - model: "claude-opus-4-6", + model: SYNTHETIC_CLAUDE_CAPABLE_MODEL, id: "msg-exit-plan", type: "message", role: "assistant", @@ -4400,6 +4669,62 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("routes Claude resume compaction through the shared user-input UI", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const session = yield* adapter.startSession({ + threadId: RESUME_THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + resumeCursor: { resume: "550e8400-e29b-41d4-a716-446655440000" }, + runtimeMode: "full-access", + }); + yield* Stream.take(adapter.streamEvents, 3).pipe(Stream.runDrain); + + const onUserDialog = harness.getLastCreateQueryInput()?.options.onUserDialog; + assert.equal(typeof onUserDialog, "function"); + if (!onUserDialog) return; + + const dialogPromise = onUserDialog( + { + dialogKind: "resume_return", + payload: { sessionAgeMinutes: 145, estimatedTokens: 275123 }, + }, + { signal: new AbortController().signal }, + ); + + const requested = yield* Stream.runHead(adapter.streamEvents); + assert.equal(requested._tag, "Some"); + if (requested._tag !== "Some" || requested.value.type !== "user-input.requested") return; + const question = requested.value.payload.questions[0]; + assert.equal(question?.header, "Resume session"); + assert.match(question?.question ?? "", /2h 25m/); + assert.match(question?.question ?? "", /275,123 tokens/); + assert.deepEqual( + question?.options.map((option) => option.label), + ["Compact and continue", "Keep full history", "Don't ask again"], + ); + if (!question || !requested.value.requestId) return; + + yield* adapter.respondToUserInput( + session.threadId, + ApprovalRequestId.make(requested.value.requestId), + { [question.id]: "Compact and continue" }, + ); + + const resolved = yield* Stream.runHead(adapter.streamEvents); + assert.equal(resolved._tag, "Some"); + if (resolved._tag === "Some") assert.equal(resolved.value.type, "user-input.resolved"); + assert.deepEqual(yield* Effect.promise(() => dialogPromise), { + behavior: "completed", + result: "compact", + }); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("handles AskUserQuestion via user-input.requested/resolved lifecycle", () => { const harness = makeHarness(); return Effect.gen(function* () { @@ -4689,6 +5014,73 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("denies AskUserQuestion when the signal aborted before the listener registered", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "approval-required", + }); + + yield* Stream.take(adapter.streamEvents, 3).pipe(Stream.runDrain); + + const canUseTool = harness.getLastCreateQueryInput()?.options.canUseTool; + assert.equal(typeof canUseTool, "function"); + if (!canUseTool) { + return; + } + + const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 2).pipe( + Stream.runCollect, + Effect.forkChild, + ); + + // Abort before the call so the adapter's listener registration can + // never observe the abort event, only the recheck can. + const controller = new AbortController(); + controller.abort(); + const permissionPromise = canUseTool( + "AskUserQuestion", + { + questions: [ + { + question: "Continue?", + header: "Continue", + options: [{ label: "Yes", description: "Proceed" }], + multiSelect: false, + }, + ], + }, + { + signal: controller.signal, + toolUseID: "tool-ask-pre-aborted", + }, + ); + + const permissionResult = yield* Effect.promise(() => permissionPromise); + assert.deepEqual(permissionResult, { + behavior: "deny", + message: "User cancelled tool execution.", + } satisfies PermissionResult); + + const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); + assert.deepEqual( + runtimeEvents.map((event) => event.type), + ["user-input.requested", "user-input.resolved"], + ); + const resolvedEvent = runtimeEvents[1]; + if (resolvedEvent?.type === "user-input.resolved") { + assert.deepEqual(resolvedEvent.payload.answers, {}); + } + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("stopping a session settles pending user-input waits", () => { const harness = makeHarness(); return Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index d42049ebefc3..c0e24231643a 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -14,7 +14,6 @@ import { type PermissionResult, type PermissionUpdate, type SDKMessage, - type SDKControlGetContextUsageResponse, type SDKResultMessage, type SettingSource, type SDKUserMessage, @@ -57,6 +56,10 @@ import { getProviderOptionDescriptors, resolvePromptInjectedEffort, } from "@t3tools/shared/model"; +import { + CLAUDE_RESUME_COMPACTION_NEVER_ANSWER, + formatClaudeResumeCompactionQuestion, +} from "@t3tools/shared/claudeCompaction"; import * as Cause from "effect/Cause"; import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; @@ -65,7 +68,6 @@ import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Fiber from "effect/Fiber"; -import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; @@ -77,14 +79,20 @@ import { ServerConfig } from "../../config.ts"; import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; import { resolveClaudeSdkExecutablePath } from "../Drivers/ClaudeExecutable.ts"; import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts"; +import { planClaudeSkillDispatch } from "../Drivers/ClaudeSkillDispatch.ts"; +import { discoverClaudeSkills } from "../Drivers/ClaudeSkills.ts"; import { - getClaudeModelCapabilities, - isClaudeUltracodeEffort, - normalizeClaudeCliEffort, - resolveClaudeApiModelId, - resolveClaudeContextWindow, - resolveClaudeEffort, -} from "./ClaudeProvider.ts"; + BUNDLED_CLAUDE_MODEL_CATALOG, + type ClaudeModelCatalog, + getClaudeCatalogModelCapabilities, + isClaudeCatalogUltracodeEffort, + normalizeClaudeCatalogEffort, + resolveClaudeCatalogApiModelId, + resolveClaudeCatalogContextWindowTokens, + resolveClaudeCatalogEffort, + resolveClaudeModelSlug, + scopeClaudeModelCatalog, +} from "../ClaudeModelCatalog.ts"; import { ProviderAdapterProcessError, ProviderAdapterRequestError, @@ -141,6 +149,8 @@ interface ClaudeTurnState { readonly assistantTextBlocks: Map; readonly assistantTextBlockOrder: Array; readonly capturedProposedPlanKeys: Set; + latestAssistantUsage: unknown | undefined; + compactedSinceLatestAssistantUsage: boolean; nextSyntheticAssistantBlockIndex: number; } @@ -317,7 +327,6 @@ interface ClaudeQueryRuntime extends AsyncIterable { readonly setModel: (model?: string) => Promise; readonly setPermissionMode: (mode: PermissionMode) => Promise; readonly setMaxThinkingTokens: (maxThinkingTokens: number | null) => Promise; - readonly getContextUsage?: () => Promise; readonly close: () => void; } @@ -330,6 +339,7 @@ export interface ClaudeAdapterLiveOptions { }) => ClaudeQueryRuntime; readonly nativeEventLogPath?: string; readonly nativeEventLogger?: EventNdjsonLogger; + readonly modelCatalog?: Effect.Effect; } function isUuid(value: string): boolean { @@ -378,10 +388,11 @@ function normalizeClaudeStreamMessages( } function getEffectiveClaudeAgentEffort( + catalog: ClaudeModelCatalog, effort: string | null | undefined, model: string | null | undefined, ): ClaudeSdkEffort | null { - const normalized = normalizeClaudeCliEffort(effort, model); + const normalized = normalizeClaudeCatalogEffort(catalog, effort, model); return normalized ? (normalized as ClaudeSdkEffort) : null; } @@ -467,23 +478,10 @@ function maxClaudeContextWindowFromModelUsage( } function selectedClaudeContextWindow( + catalog: ClaudeModelCatalog, modelSelection: ModelSelection | undefined, ): number | undefined { - switch (modelSelection?.model) { - case "claude-opus-4-8": - case "claude-opus-4-7": - // Always 1M at the API; these models expose no contextWindow option. - return 1_000_000; - } - - switch (resolveClaudeContextWindow(modelSelection)) { - case "1m": - return 1_000_000; - case "200k": - return 200_000; - default: - return undefined; - } + return resolveClaudeCatalogContextWindowTokens(catalog, modelSelection); } function finiteNonNegativeInteger(value: unknown): number | undefined { @@ -543,6 +541,7 @@ function makeClaudeTokenUsageSnapshot(input: { readonly totalProcessedTokens?: number; readonly lastUsedTokens?: number; readonly compactsAutomatically?: boolean; + readonly autoCompactThreshold?: number; }): ThreadTokenUsageSnapshot | undefined { const activeTokens = finiteNonNegativeInteger(input.activeTokens); if (activeTokens === undefined || activeTokens <= 0) { @@ -570,6 +569,9 @@ function makeClaudeTokenUsageSnapshot(input: { ...(input.compactsAutomatically !== undefined ? { compactsAutomatically: input.compactsAutomatically } : {}), + ...(input.autoCompactThreshold !== undefined + ? { autoCompactThreshold: input.autoCompactThreshold } + : {}), }; } @@ -600,18 +602,6 @@ function normalizeClaudeActiveTokenUsage( }); } -function normalizeClaudeContextUsageApiSnapshot( - value: SDKControlGetContextUsageResponse, - totalProcessedTokens?: number, -): ThreadTokenUsageSnapshot | undefined { - return makeClaudeTokenUsageSnapshot({ - activeTokens: value.totalTokens, - contextWindow: value.maxTokens, - ...(totalProcessedTokens !== undefined ? { totalProcessedTokens } : {}), - compactsAutomatically: value.isAutoCompactEnabled, - }); -} - function compactBoundaryTokenUsageSnapshot( message: Record, contextWindow?: number, @@ -1239,6 +1229,7 @@ const CLAUDE_SETTING_SOURCES = [ function buildPromptText( input: ProviderSendTurnInput, boundInstanceId: ProviderInstanceId, + catalog: ClaudeModelCatalog, ): string { const rawEffort = input.modelSelection?.instanceId === boundInstanceId @@ -1246,7 +1237,7 @@ function buildPromptText( : null; const claudeModel = input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection.model : undefined; - const caps = getClaudeModelCapabilities(claudeModel); + const caps = getClaudeCatalogModelCapabilities(catalog, claudeModel); const promptEffort = resolvePromptInjectedEffort(caps, rawEffort); return applyClaudePromptEffortPrefix(input.input?.trim() ?? "", promptEffort); @@ -1286,16 +1277,30 @@ const buildUserMessageEffect = Effect.fn("buildUserMessageEffect")(function* ( readonly fileSystem: FileSystem.FileSystem; readonly attachmentsDir: string; readonly boundInstanceId: ProviderInstanceId; + readonly modelCatalog: ClaudeModelCatalog; + /** Names of the skills Claude Code can run for this session's cwd. */ + readonly skillNames: ReadonlySet; }, ) { - const text = buildPromptText(input, dependencies.boundInstanceId); + const text = buildPromptText(input, dependencies.boundInstanceId, dependencies.modelCatalog); const sdkContent: Array> = []; - if (text.length > 0) { + // Claude Code expands a skill only from the LAST text block, and only when + // `/name` is its first character. A `$skill` chip anywhere in the prompt is + // therefore split into [leading text, "/name trailing text"] so the CLI + // runs it natively and the prose around it survives. See ClaudeSkillDispatch. + const dispatch = planClaudeSkillDispatch(text, dependencies.skillNames); + if (dispatch) { + if (dispatch.leadingText !== undefined) { + sdkContent.push({ type: "text", text: dispatch.leadingText }); + } + } else if (text.length > 0) { sdkContent.push({ type: "text", text }); } for (const attachment of input.attachments ?? []) { + // Claude ingests images only. Generic files reach the agent through the + // path line ProviderService puts in the prompt. if (attachment.type !== "image") { continue; } @@ -1340,6 +1345,12 @@ const buildUserMessageEffect = Effect.fn("buildUserMessageEffect")(function* ( ); } + // Images go before the command block: a text block after them still + // expands, a command block followed by an image does not. + if (dispatch) { + sdkContent.push({ type: "text", text: dispatch.commandText }); + } + return buildUserMessage({ sdkContent }); }); @@ -1685,6 +1696,9 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( options?: ClaudeAdapterLiveOptions, ) { const boundInstanceId = options?.instanceId ?? ProviderInstanceId.make("claudeAgent"); + const modelCatalogEffect = ( + options?.modelCatalog ?? Effect.succeed(BUNDLED_CLAUDE_MODEL_CATALOG) + ).pipe(Effect.map((catalog) => scopeClaudeModelCatalog(catalog, claudeSettings.customModels))); const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const serverConfig = yield* ServerConfig; @@ -2121,29 +2135,6 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( }); }); - const queryCurrentContextUsage = Effect.fn("queryCurrentContextUsage")(function* ( - context: ClaudeSessionContext, - totalProcessedTokens?: number, - ) { - if (!context.query.getContextUsage) { - return undefined; - } - - const usage = yield* Effect.promise(async () => { - try { - return await context.query.getContextUsage?.(); - } catch { - return undefined; - } - }).pipe(Effect.timeoutOption("1 second")); - if (Option.isNone(usage) || !usage.value) { - return undefined; - } - - context.lastKnownContextWindow = usage.value.maxTokens; - return normalizeClaudeContextUsageApiSnapshot(usage.value, totalProcessedTokens); - }); - const emitProposedPlanCompleted = Effect.fn("emitProposedPlanCompleted")(function* ( context: ClaudeSessionContext, input: { @@ -2244,10 +2235,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( context.lastKnownTotalProcessedTokens = accumulatedTotalProcessedTokens; } - const contextUsageSnapshot = yield* queryCurrentContextUsage( - context, - accumulatedTotalProcessedTokens ?? context.lastKnownTotalProcessedTokens, - ); + // Avoid getContextUsage because its token-count fallback can make extra model requests. const resultUsageRecord = result?.usage && typeof result.usage === "object" && !Array.isArray(result.usage) ? (result.usage as Record) @@ -2269,24 +2257,31 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( accumulatedTotalProcessedTokens ?? context.lastKnownTotalProcessedTokens, ) : undefined; + const latestAssistantSnapshot = normalizeClaudeActiveTokenUsage( + context.turnState?.latestAssistantUsage, + maxTokens, + accumulatedTotalProcessedTokens ?? context.lastKnownTotalProcessedTokens, + ); const lastGoodUsage = context.lastKnownTokenUsage; const usageSnapshot: ThreadTokenUsageSnapshot | undefined = - contextUsageSnapshot ?? - (resultTotalOnly && lastGoodUsage - ? { - ...lastGoodUsage, - ...(typeof maxTokens === "number" && Number.isFinite(maxTokens) && maxTokens > 0 - ? { maxTokens } - : {}), - ...(typeof accumulatedTotalProcessedTokens === "number" && - Number.isFinite(accumulatedTotalProcessedTokens) && - accumulatedTotalProcessedTokens > lastGoodUsage.usedTokens - ? { - totalProcessedTokens: accumulatedTotalProcessedTokens, - } - : {}), - } - : resultIterationSnapshot) ?? + latestAssistantSnapshot ?? + (context.turnState?.compactedSinceLatestAssistantUsage + ? undefined + : resultTotalOnly && lastGoodUsage + ? { + ...lastGoodUsage, + ...(typeof maxTokens === "number" && Number.isFinite(maxTokens) && maxTokens > 0 + ? { maxTokens } + : {}), + ...(typeof accumulatedTotalProcessedTokens === "number" && + Number.isFinite(accumulatedTotalProcessedTokens) && + accumulatedTotalProcessedTokens > lastGoodUsage.usedTokens + ? { + totalProcessedTokens: accumulatedTotalProcessedTokens, + } + : {}), + } + : resultIterationSnapshot) ?? (lastGoodUsage ? { ...lastGoodUsage, @@ -2938,6 +2933,8 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( assistantTextBlocks: new Map(), assistantTextBlockOrder: [], capturedProposedPlanKeys: new Set(), + latestAssistantUsage: undefined, + compactedSinceLatestAssistantUsage: false, nextSyntheticAssistantBlockIndex: -1, }; context.session = { @@ -2998,6 +2995,16 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( if (context.turnState) { context.turnState.items.push(message.message); + if ( + normalizeClaudeActiveTokenUsage( + message.message.usage, + context.lastKnownContextWindow, + context.lastKnownTotalProcessedTokens, + ) + ) { + context.turnState.latestAssistantUsage = message.message.usage; + context.turnState.compactedSinceLatestAssistantUsage = false; + } yield* backfillAssistantTextBlocksFromSnapshot(context, message); } @@ -3162,6 +3169,10 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( }); return; case "compact_boundary": + if (context.turnState) { + context.turnState.latestAssistantUsage = undefined; + context.turnState.compactedSinceLatestAssistantUsage = true; + } yield* emitThreadTokenUsage( context, compactBoundaryTokenUsageSnapshot( @@ -3811,6 +3822,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const startSession: ClaudeAdapterShape["startSession"] = Effect.fn("startSession")( function* (input) { + const modelCatalog = yield* modelCatalogEffect; if (input.provider !== undefined && input.provider !== PROVIDER) { return yield* new ProviderAdapterValidationError({ provider: PROVIDER, @@ -3953,6 +3965,12 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( callbackOptions.signal.addEventListener("abort", onAbort, { once: true, }); + // The signal may have aborted during the awaited event emissions + // above, before the listener existed; settle now so the dialog + // cannot hang with a lingering pending question. + if (callbackOptions.signal.aborted) { + yield* settleAsAborted; + } // Block until the user provides answers. const answers = yield* Deferred.await(answersDeferred); @@ -4001,6 +4019,76 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( } satisfies PermissionResult; }); + const handleResumeDialog = Effect.fn("handleResumeDialog")(function* ( + request: Parameters>[0], + callbackOptions: Parameters>[1], + ) { + if (request.dialogKind !== "resume_return") { + return { behavior: "cancelled" as const }; + } + + const context = yield* Ref.get(contextRef); + if (!context) { + return { behavior: "cancelled" as const }; + } + + // The question copy lives in @t3tools/shared/claudeCompaction because + // the web client recognizes this exact text (and the "never" answer) + // to mirror a permanent dismissal. + const question = formatClaudeResumeCompactionQuestion({ + ageMinutes: finiteNonNegativeInteger(request.payload.sessionAgeMinutes) ?? 0, + estimatedTokens: finiteNonNegativeInteger(request.payload.estimatedTokens) ?? 0, + }); + const result = yield* handleAskUserQuestion( + context, + { + questions: [ + { + header: "Resume session", + question, + options: [ + { + label: "Compact and continue", + description: "Resume with a summary and use fewer tokens.", + }, + { + label: "Keep full history", + description: "Resume without changing the conversation.", + }, + { + label: CLAUDE_RESUME_COMPACTION_NEVER_ANSWER, + description: "Keep full history and skip future resume prompts.", + }, + ], + multiSelect: false, + }, + ], + }, + { + signal: callbackOptions.signal, + ...(request.toolUseID ? { toolUseID: request.toolUseID } : {}), + }, + ); + + if (result.behavior !== "allow") { + return { behavior: "cancelled" as const }; + } + + const answers = result.updatedInput.answers; + const selection = + answers && typeof answers === "object" && !Array.isArray(answers) + ? (answers as Record)[question] + : undefined; + const action = + selection === "Compact and continue" + ? "compact" + : selection === CLAUDE_RESUME_COMPACTION_NEVER_ANSWER + ? "never" + : "continue"; + + return { behavior: "completed" as const, result: action }; + }); + const canUseToolEffect = Effect.fn("canUseTool")(function* ( toolName: Parameters[0], toolInput: Parameters[1], @@ -4106,6 +4194,11 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( callbackOptions.signal.addEventListener("abort", onAbort, { once: true, }); + // Same late-listener race as handleAskUserQuestion: the signal may + // have aborted while the request event emissions were awaited. + if (callbackOptions.signal.aborted) { + onAbort(); + } const decision = yield* Deferred.await(decisionDeferred); pendingApprovals.delete(requestId); @@ -4161,17 +4254,30 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const canUseTool: CanUseTool = (toolName, toolInput, callbackOptions) => runPromise(canUseToolEffect(toolName, toolInput, callbackOptions)); + const onUserDialog: NonNullable = ( + request, + callbackOptions, + ) => runPromise(handleResumeDialog(request, callbackOptions)); const claudeBinaryPath = claudeSdkExecutablePath; const extraArgs = parseCliArgs(claudeSettings.launchArgs).flags; - const modelSelection = + const selectedModel = input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined; - const caps = getClaudeModelCapabilities(modelSelection?.model); + const modelSelection = selectedModel + ? { + ...selectedModel, + model: resolveClaudeModelSlug(modelCatalog, selectedModel.model), + } + : undefined; + const caps = getClaudeCatalogModelCapabilities(modelCatalog, modelSelection?.model); const descriptors = getProviderOptionDescriptors({ caps }); - const apiModelId = modelSelection ? resolveClaudeApiModelId(modelSelection) : undefined; - const initialContextWindow = selectedClaudeContextWindow(modelSelection); + const apiModelId = modelSelection + ? resolveClaudeCatalogApiModelId(modelCatalog, modelSelection) + : undefined; + const initialContextWindow = selectedClaudeContextWindow(modelCatalog, modelSelection); const rawEffort = getModelSelectionStringOptionValue(modelSelection, "effort"); - const effort = resolveClaudeEffort(caps, rawEffort) ?? null; + const effort = + resolveClaudeCatalogEffort(modelCatalog, modelSelection?.model, rawEffort) ?? null; const fastModeSupported = descriptors.some( (descriptor) => descriptor.type === "boolean" && descriptor.id === "fastMode", ); @@ -4184,8 +4290,12 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const thinking = thinkingSupported ? getModelSelectionBooleanOptionValue(modelSelection, "thinking") : undefined; - const ultracode = isClaudeUltracodeEffort(effort); - const effectiveEffort = getEffectiveClaudeAgentEffort(effort, modelSelection?.model); + const ultracode = isClaudeCatalogUltracodeEffort(effort); + const effectiveEffort = getEffectiveClaudeAgentEffort( + modelCatalog, + effort, + modelSelection?.model, + ); const runtimeModeToPermission: Record = { "auto-accept-edits": "acceptEdits", auto: "auto", @@ -4196,6 +4306,9 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ...(typeof thinking === "boolean" ? { alwaysThinkingEnabled: thinking } : {}), ...(fastMode ? { fastMode: true } : {}), ...(ultracode ? { ultracode: true } : {}), + ...(claudeSettings.autoCompactWindow + ? { autoCompactWindow: Number(claudeSettings.autoCompactWindow) } + : {}), }; const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); // The attachments dir grant lets the agent Read/copy pasted images at @@ -4228,6 +4341,8 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ...(newSessionId ? { sessionId: newSessionId } : {}), includePartialMessages: true, canUseTool, + onUserDialog, + supportedDialogKinds: ["resume_return"], env: claudeEnvironment, additionalDirectories, ...(Object.keys(extraArgs).length > 0 ? { extraArgs } : {}), @@ -4411,10 +4526,14 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const sendTurn: ClaudeAdapterShape["sendTurn"] = Effect.fn("sendTurn")(function* (input) { const context = yield* requireSession(input.threadId); - const modelSelection = + const modelCatalog = yield* modelCatalogEffect; + const selectedModel = input.modelSelection !== undefined && input.modelSelection.instanceId === boundInstanceId ? input.modelSelection : undefined; + const modelSelection = selectedModel + ? { ...selectedModel, model: resolveClaudeModelSlug(modelCatalog, selectedModel.model) } + : undefined; // A sendTurn while a real turn is running is a steer: the message is // queued into the live SDK agent loop and the work continues as the same @@ -4428,7 +4547,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( } if (modelSelection?.model) { - const apiModelId = resolveClaudeApiModelId(modelSelection); + const apiModelId = resolveClaudeCatalogApiModelId(modelCatalog, modelSelection); if (context.currentApiModelId !== apiModelId) { yield* Effect.tryPromise({ try: () => context.query.setModel(apiModelId), @@ -4440,13 +4559,14 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ...context.session, model: modelSelection.model, }; - const turnCaps = getClaudeModelCapabilities(modelSelection.model); - const turnEffort = resolveClaudeEffort( - turnCaps, + const turnEffort = resolveClaudeCatalogEffort( + modelCatalog, + modelSelection.model, getModelSelectionStringOptionValue(modelSelection, "effort"), ); context.currentEffort = - getEffectiveClaudeAgentEffort(turnEffort ?? null, modelSelection.model) ?? undefined; + getEffectiveClaudeAgentEffort(modelCatalog, turnEffort ?? null, modelSelection.model) ?? + undefined; } // Apply interaction mode by switching the SDK's permission mode. @@ -4474,6 +4594,8 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( assistantTextBlocks: new Map(), assistantTextBlockOrder: [], capturedProposedPlanKeys: new Set(), + latestAssistantUsage: undefined, + compactedSinceLatestAssistantUsage: false, nextSyntheticAssistantBlockIndex: -1, }; @@ -4499,10 +4621,29 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( }); } + // Re-scan on every send: skills are added and switched off mid-session, + // and the scan is a few directory reads. A skill switched off via + // skillOverrides, or reserved for the agent with `user-invocable: false`, + // is left as prose: the CLI would answer `/name` with a notice instead of + // running it. + const skills = yield* discoverClaudeSkills( + claudeSettings, + context.session.cwd, + claudeEnvironment, + ).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ); const message = yield* buildUserMessageEffect(input, { fileSystem, attachmentsDir: serverConfig.attachmentsDir, boundInstanceId, + modelCatalog, + skillNames: new Set( + skills + .filter((skill) => skill.enabled && skill.userInvocable !== false) + .map((skill) => skill.name), + ), }); yield* Queue.offer(context.promptQueue, { diff --git a/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts b/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts index 040e63b80229..2f842bf581f7 100644 --- a/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts +++ b/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts @@ -9,27 +9,11 @@ import * as Schema from "effect/Schema"; import { buildClaudeCapabilitiesProbeQueryOptions, CLAUDE_CAPABILITIES_PROBE_SETTING_SOURCES, - isLegacyClaudeModel, probeClaudeCapabilities, } from "./ClaudeProvider.ts"; const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings); -it("keeps only the Claude 5 family out of legacy models", () => { - assert.deepStrictEqual( - ["claude-fable-5", "claude-opus-5", "claude-sonnet-5", "claude-opus-4-8"].map((model) => [ - model, - isLegacyClaudeModel(model), - ]), - [ - ["claude-fable-5", false], - ["claude-opus-5", false], - ["claude-sonnet-5", false], - ["claude-opus-4-8", true], - ], - ); -}); - it("isolates Claude capability probes without dropping workspace setting sources", () => { const abortController = new AbortController(); const options = buildClaudeCapabilitiesProbeQueryOptions({ @@ -38,6 +22,7 @@ it("isolates Claude capability probes without dropping workspace setting sources environment: { HOME: "/home/user", ENABLE_CLAUDEAI_MCP_SERVERS: "true", + FORCE_CODE_TERMINAL: "1", }, cwd: "/workspace/project", }); @@ -53,6 +38,9 @@ it("isolates Claude capability probes without dropping workspace setting sources assert.equal(options.abortController, abortController); assert.equal(options.env?.HOME, "/home/user"); assert.equal(options.env?.ENABLE_CLAUDEAI_MCP_SERVERS, "false"); + assert.equal(options.env?.FORCE_CODE_TERMINAL, undefined); + assert.equal(options.env?.CLAUDE_CODE_AUTO_CONNECT_IDE, "0"); + assert.equal(options.env?.CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL, "1"); }); it.layer(NodeServices.layer)("Claude capability probe SDK boundary", (it) => { diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index 806f7e19b905..bf41046f61e8 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -1,8 +1,6 @@ import { type ClaudeSettings, type ModelCapabilities, - type ModelSelection, - type ServerProviderModel, type ServerProviderSlashCommand, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; @@ -12,14 +10,8 @@ import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Result from "effect/Result"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; -import { - createModelCapabilities, - getModelSelectionStringOptionValue, - getProviderOptionCurrentValue, - getProviderOptionDescriptors, -} from "@t3tools/shared/model"; +import { createModelCapabilities } from "@t3tools/shared/model"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; -import { compareSemverVersions } from "@t3tools/shared/semver"; import { query as claudeQuery, type Options as ClaudeQueryOptions, @@ -29,8 +21,6 @@ import { } from "@anthropic-ai/claude-agent-sdk"; import { - buildBooleanOptionDescriptor, - buildSelectOptionDescriptor, buildServerProvider, DEFAULT_TIMEOUT_MS, isCommandMissingCause, @@ -42,6 +32,12 @@ import { import { resolveClaudeSdkExecutablePath } from "../Drivers/ClaudeExecutable.ts"; import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts"; import { discoverClaudeSkills } from "../Drivers/ClaudeSkills.ts"; +import { + BUNDLED_CLAUDE_MODEL_CATALOG, + type ClaudeModelCatalog, + formatClaudeVersionUpgradeMessage, + resolveClaudeModelsForVersion, +} from "../ClaudeModelCatalog.ts"; const DEFAULT_CLAUDE_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({ optionDescriptors: [], @@ -51,425 +47,6 @@ const CLAUDE_PRESENTATION = { displayName: "Claude", showInteractionModeToggle: true, } as const; -const MINIMUM_CLAUDE_OPUS_5_VERSION = "2.1.219"; -const MINIMUM_CLAUDE_FABLE_5_VERSION = "2.1.169"; -const MINIMUM_CLAUDE_OPUS_4_8_VERSION = "2.1.154"; -const MINIMUM_CLAUDE_OPUS_4_7_VERSION = "2.1.111"; - -const CURRENT_CLAUDE_MODELS = new Set(["claude-fable-5", "claude-opus-5", "claude-sonnet-5"]); - -export function isLegacyClaudeModel(model: string): boolean { - return !CURRENT_CLAUDE_MODELS.has(model); -} - -const CLAUDE_MODEL_CATALOG: ReadonlyArray = [ - { - slug: "claude-fable-5", - name: "Claude Fable 5", - isCustom: false, - capabilities: createModelCapabilities({ - optionDescriptors: [ - buildSelectOptionDescriptor({ - id: "effort", - label: "Reasoning", - options: [ - { value: "low", label: "Low" }, - { value: "medium", label: "Medium" }, - { value: "high", label: "High", isDefault: true }, - { value: "xhigh", label: "Extra High" }, - { value: "max", label: "Max" }, - { - value: "ultracode", - label: "Ultracode", - description: "xhigh effort plus multi-agent workflow orchestration", - }, - { value: "ultrathink", label: "Ultrathink" }, - ], - promptInjectedValues: ["ultrathink"], - }), - buildSelectOptionDescriptor({ - id: "contextWindow", - label: "Context Window", - options: [ - { value: "200k", label: "200k" }, - { value: "1m", label: "1M", isDefault: true }, - ], - }), - ], - }), - }, - { - slug: "claude-opus-5", - name: "Claude Opus 5", - isCustom: false, - capabilities: createModelCapabilities({ - optionDescriptors: [ - buildSelectOptionDescriptor({ - id: "effort", - label: "Reasoning", - options: [ - { value: "low", label: "Low" }, - { value: "medium", label: "Medium" }, - { value: "high", label: "High", isDefault: true }, - { value: "xhigh", label: "Extra High" }, - { value: "max", label: "Max" }, - { - value: "ultracode", - label: "Ultracode", - description: "xhigh effort plus multi-agent workflow orchestration", - }, - { value: "ultrathink", label: "Ultrathink" }, - ], - promptInjectedValues: ["ultrathink"], - }), - buildBooleanOptionDescriptor({ - id: "fastMode", - label: "Fast Mode", - }), - buildSelectOptionDescriptor({ - id: "contextWindow", - label: "Context Window", - // Claude Code selects the 1M variant explicitly (`claude-opus-5[1m]`). - options: [ - { value: "200k", label: "200k" }, - { value: "1m", label: "1M", isDefault: true }, - ], - }), - ], - }), - }, - { - slug: "claude-opus-4-8", - name: "Claude Opus 4.8", - isCustom: false, - capabilities: createModelCapabilities({ - optionDescriptors: [ - buildSelectOptionDescriptor({ - id: "effort", - label: "Reasoning", - options: [ - { value: "low", label: "Low" }, - { value: "medium", label: "Medium" }, - { value: "high", label: "High", isDefault: true }, - { value: "xhigh", label: "Extra High" }, - { value: "max", label: "Max" }, - { - value: "ultracode", - label: "Ultracode", - description: "xhigh effort plus multi-agent workflow orchestration", - }, - { value: "ultrathink", label: "Ultrathink" }, - ], - promptInjectedValues: ["ultrathink"], - }), - buildBooleanOptionDescriptor({ - id: "fastMode", - label: "Fast Mode", - }), - ], - }), - }, - { - slug: "claude-opus-4-7", - name: "Claude Opus 4.7", - isCustom: false, - capabilities: createModelCapabilities({ - optionDescriptors: [ - buildSelectOptionDescriptor({ - id: "effort", - label: "Reasoning", - options: [ - { value: "low", label: "Low" }, - { value: "medium", label: "Medium" }, - { value: "high", label: "High" }, - { value: "xhigh", label: "Extra High", isDefault: true }, - { value: "max", label: "Max" }, - { value: "ultrathink", label: "Ultrathink" }, - ], - promptInjectedValues: ["ultrathink"], - }), - buildBooleanOptionDescriptor({ - id: "fastMode", - label: "Fast Mode", - }), - ], - }), - }, - { - slug: "claude-opus-4-6", - name: "Claude Opus 4.6", - isCustom: false, - capabilities: createModelCapabilities({ - optionDescriptors: [ - buildSelectOptionDescriptor({ - id: "effort", - label: "Reasoning", - options: [ - { value: "low", label: "Low" }, - { value: "medium", label: "Medium" }, - { value: "high", label: "High", isDefault: true }, - { value: "max", label: "Max" }, - { value: "ultrathink", label: "Ultrathink" }, - ], - promptInjectedValues: ["ultrathink"], - }), - buildBooleanOptionDescriptor({ - id: "fastMode", - label: "Fast Mode", - }), - buildSelectOptionDescriptor({ - id: "contextWindow", - label: "Context Window", - options: [ - { value: "200k", label: "200k" }, - { value: "1m", label: "1M", isDefault: true }, - ], - }), - ], - }), - }, - { - slug: "claude-opus-4-5", - name: "Claude Opus 4.5", - isCustom: false, - capabilities: createModelCapabilities({ - optionDescriptors: [ - buildSelectOptionDescriptor({ - id: "effort", - label: "Reasoning", - options: [ - { value: "low", label: "Low" }, - { value: "medium", label: "Medium" }, - { value: "high", label: "High", isDefault: true }, - { value: "max", label: "Max" }, - ], - }), - buildBooleanOptionDescriptor({ - id: "fastMode", - label: "Fast Mode", - }), - ], - }), - }, - { - slug: "claude-sonnet-5", - name: "Claude Sonnet 5", - isCustom: false, - capabilities: createModelCapabilities({ - optionDescriptors: [ - buildSelectOptionDescriptor({ - id: "effort", - label: "Reasoning", - options: [ - { value: "low", label: "Low" }, - { value: "medium", label: "Medium" }, - { value: "high", label: "High", isDefault: true }, - { value: "xhigh", label: "Extra High" }, - { value: "max", label: "Max" }, - { value: "ultrathink", label: "Ultrathink" }, - ], - promptInjectedValues: ["ultrathink"], - }), - buildSelectOptionDescriptor({ - id: "contextWindow", - label: "Context Window", - // Sonnet is 200k-default in Claude Code (1M is opt-in there too). - options: [ - { value: "200k", label: "200k", isDefault: true }, - { value: "1m", label: "1M" }, - ], - }), - ], - }), - }, - { - slug: "claude-sonnet-4-6", - name: "Claude Sonnet 4.6", - isCustom: false, - capabilities: createModelCapabilities({ - optionDescriptors: [ - buildSelectOptionDescriptor({ - id: "effort", - label: "Reasoning", - options: [ - { value: "low", label: "Low" }, - { value: "medium", label: "Medium" }, - { value: "high", label: "High", isDefault: true }, - { value: "max", label: "Max" }, - { value: "ultrathink", label: "Ultrathink" }, - ], - promptInjectedValues: ["ultrathink"], - }), - buildSelectOptionDescriptor({ - id: "contextWindow", - label: "Context Window", - // Sonnet is 200k-default in Claude Code (1M is opt-in there too). - options: [ - { value: "200k", label: "200k", isDefault: true }, - { value: "1m", label: "1M" }, - ], - }), - ], - }), - }, - { - slug: "claude-haiku-4-5", - name: "Claude Haiku 4.5", - isCustom: false, - capabilities: createModelCapabilities({ - optionDescriptors: [ - buildBooleanOptionDescriptor({ - id: "thinking", - label: "Thinking", - }), - ], - }), - }, -]; - -const BUILT_IN_MODELS: ReadonlyArray = CLAUDE_MODEL_CATALOG.map((model) => - isLegacyClaudeModel(model.slug) ? { ...model, isLegacy: true } : model, -); - -function supportsClaudeOpus5(version: string | null | undefined): boolean { - return version ? compareSemverVersions(version, MINIMUM_CLAUDE_OPUS_5_VERSION) >= 0 : false; -} - -function supportsClaudeFable5(version: string | null | undefined): boolean { - return version ? compareSemverVersions(version, MINIMUM_CLAUDE_FABLE_5_VERSION) >= 0 : false; -} - -function supportsClaudeOpus48(version: string | null | undefined): boolean { - return version ? compareSemverVersions(version, MINIMUM_CLAUDE_OPUS_4_8_VERSION) >= 0 : false; -} - -function supportsClaudeOpus47(version: string | null | undefined): boolean { - return version ? compareSemverVersions(version, MINIMUM_CLAUDE_OPUS_4_7_VERSION) >= 0 : false; -} - -function getBuiltInClaudeModelsForVersion( - version: string | null | undefined, -): ReadonlyArray { - return BUILT_IN_MODELS.filter((model) => { - if (model.slug === "claude-opus-5") { - return supportsClaudeOpus5(version); - } - if (model.slug === "claude-fable-5") { - return supportsClaudeFable5(version); - } - if (model.slug === "claude-opus-4-8") { - return supportsClaudeOpus48(version); - } - if (model.slug === "claude-opus-4-7") { - return supportsClaudeOpus47(version); - } - return true; - }); -} - -function formatClaudeOpus5UpgradeMessage(version: string | null): string { - const versionLabel = version ? `v${version}` : "the installed version"; - return `Claude Code ${versionLabel} is too old for Claude Opus 5. Upgrade to v${MINIMUM_CLAUDE_OPUS_5_VERSION} or newer to access it.`; -} - -function formatClaudeFable5UpgradeMessage(version: string | null): string { - const versionLabel = version ? `v${version}` : "the installed version"; - return `Claude Code ${versionLabel} is too old for Claude Fable 5. Upgrade to v${MINIMUM_CLAUDE_FABLE_5_VERSION} or newer to access it.`; -} - -function formatClaudeOpus48UpgradeMessage(version: string | null): string { - const versionLabel = version ? `v${version}` : "the installed version"; - return `Claude Code ${versionLabel} is too old for Claude Opus 4.8. Upgrade to v${MINIMUM_CLAUDE_OPUS_4_8_VERSION} or newer to access it.`; -} - -function formatClaudeOpus47UpgradeMessage(version: string | null): string { - const versionLabel = version ? `v${version}` : "the installed version"; - return `Claude Code ${versionLabel} is too old for Claude Opus 4.7. Upgrade to v${MINIMUM_CLAUDE_OPUS_4_7_VERSION} or newer to access it.`; -} - -export function getClaudeModelCapabilities(model: string | null | undefined): ModelCapabilities { - const slug = model?.trim(); - return ( - BUILT_IN_MODELS.find((candidate) => candidate.slug === slug)?.capabilities ?? - DEFAULT_CLAUDE_MODEL_CAPABILITIES - ); -} - -export function resolveClaudeEffort( - caps: ModelCapabilities, - raw: string | null | undefined, -): string | undefined { - const descriptors = getProviderOptionDescriptors({ - caps, - ...(raw ? { selections: [{ id: "effort", value: raw }] } : {}), - }); - const effortDescriptor = descriptors.find((descriptor) => descriptor.id === "effort"); - const value = getProviderOptionCurrentValue(effortDescriptor); - return typeof value === "string" ? value : undefined; -} - -/** - * Normalize a resolved Claude effort value into one suitable for the Claude - * CLI's `--effort` flag. - * - * Mirrors the mapping used when invoking the Claude Agent SDK - * ({@link getEffectiveClaudeAgentEffort} in ClaudeAdapter): `ultracode` is a - * Claude Code setting that pairs with `xhigh`, `ultrathink` is filtered out - * because it is a prompt-prefix mode, and older model compatibility mappings - * are preserved for current Claude Code behavior. - */ -export function normalizeClaudeCliEffort( - effort: string | null | undefined, - model: string | null | undefined, -): string | undefined { - if (!effort || effort === "ultrathink") { - return undefined; - } - if (effort === "ultracode") { - return "xhigh"; - } - if ( - effort === "xhigh" && - model !== "claude-fable-5" && - model !== "claude-opus-5" && - model !== "claude-opus-4-8" && - model !== "claude-sonnet-5" - ) { - return "max"; - } - if (effort === "max" && model === "claude-sonnet-4-6") { - return "high"; - } - return effort; -} - -export function isClaudeUltracodeEffort(effort: string | null | undefined): boolean { - return effort === "ultracode"; -} - -export function resolveClaudeContextWindow( - modelSelection: ModelSelection | undefined, -): string | undefined { - const caps = getClaudeModelCapabilities(modelSelection?.model); - const raw = getModelSelectionStringOptionValue(modelSelection, "contextWindow"); - const descriptors = getProviderOptionDescriptors({ - caps, - ...(raw ? { selections: [{ id: "contextWindow", value: raw }] } : {}), - }); - const descriptor = descriptors.find((candidate) => candidate.id === "contextWindow"); - const value = getProviderOptionCurrentValue(descriptor); - return typeof value === "string" ? value : undefined; -} - -export function resolveClaudeApiModelId(modelSelection: ModelSelection): string { - switch (resolveClaudeContextWindow(modelSelection)) { - case "1m": - return `${modelSelection.model}[1m]`; - default: - return modelSelection.model; - } -} - function toTitleCaseWords(value: string): string { const parts: Array = []; for (const part of value.split(/[\s_-]+/g)) { @@ -620,6 +197,12 @@ export function buildClaudeCapabilitiesProbeQueryOptions(input: { // Connected claude.ai MCP servers are discovered outside filesystem // config; disable them independently for this health check. ENABLE_CLAUDEAI_MCP_SERVERS: "false", + // This is a noninteractive health check, so IDE discovery cannot add any + // useful capability data. Skipping it also avoids Claude spawning a + // Windows `tasklist | findstr` process tree on every periodic refresh. + FORCE_CODE_TERMINAL: undefined, + CLAUDE_CODE_AUTO_CONNECT_IDE: "0", + CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL: "1", }, ...(input.cwd ? { cwd: input.cwd } : {}), stderr: () => {}, @@ -811,6 +394,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( ) => Effect.Effect, environment?: NodeJS.ProcessEnv, cwd?: string, + modelCatalog: ClaudeModelCatalog = BUNDLED_CLAUDE_MODEL_CATALOG, ): Effect.fn.Return< ServerProviderDraft, never, @@ -819,7 +403,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( const resolvedEnvironment = environment ?? process.env; const checkedAt = DateTime.formatIso(yield* DateTime.now); const allModels = providerModelsFromSettings( - BUILT_IN_MODELS, + modelCatalog.models.map((entry) => entry.model), claudeSettings.customModels, DEFAULT_CLAUDE_MODEL_CAPABILITIES, ); @@ -909,25 +493,23 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( } const models = providerModelsFromSettings( - getBuiltInClaudeModelsForVersion(parsedVersion), + resolveClaudeModelsForVersion(modelCatalog, parsedVersion), claudeSettings.customModels, DEFAULT_CLAUDE_MODEL_CAPABILITIES, ); - const versionUpgradeMessage = supportsClaudeOpus5(parsedVersion) - ? undefined - : supportsClaudeFable5(parsedVersion) - ? formatClaudeOpus5UpgradeMessage(parsedVersion) - : supportsClaudeOpus48(parsedVersion) - ? formatClaudeFable5UpgradeMessage(parsedVersion) - : supportsClaudeOpus47(parsedVersion) - ? formatClaudeOpus48UpgradeMessage(parsedVersion) - : formatClaudeOpus47UpgradeMessage(parsedVersion); + const versionUpgradeMessage = formatClaudeVersionUpgradeMessage(modelCatalog, parsedVersion); const capabilities = resolveCapabilities ? yield* resolveCapabilities(claudeSettings).pipe(Effect.orElseSucceed(() => undefined)) : undefined; const skills = yield* discoverClaudeSkills(claudeSettings, cwd, resolvedEnvironment); - const slashCommands = capabilities?.slashCommands ?? []; + const slashCommands = [ + { + name: "compact", + description: "Summarize the conversation and reduce context usage", + }, + ...(capabilities?.slashCommands ?? []), + ]; const dedupedSlashCommands = dedupeSlashCommands(slashCommands); if (!capabilities) { @@ -978,11 +560,12 @@ const nowIso = Effect.map(DateTime.now, DateTime.formatIso); export const makePendingClaudeProvider = ( claudeSettings: ClaudeSettings, + modelCatalog: ClaudeModelCatalog = BUNDLED_CLAUDE_MODEL_CATALOG, ): Effect.Effect => Effect.gen(function* () { const checkedAt = yield* nowIso; const models = providerModelsFromSettings( - BUILT_IN_MODELS, + modelCatalog.models.map((entry) => entry.model), claudeSettings.customModels, DEFAULT_CLAUDE_MODEL_CAPABILITIES, ); diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index 4986d02c9b67..f01192f8d707 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -557,6 +557,89 @@ function startLifecycleRuntime() { } lifecycleLayer("CodexAdapterLive lifecycle", (it) => { + it.effect("carries child model metadata through every task event", () => + Effect.gen(function* () { + const { adapter, runtime } = yield* startLifecycleRuntime(); + const eventsFiber = yield* Stream.runCollect(Stream.take(adapter.streamEvents, 10)).pipe( + Effect.forkChild, + ); + + const cases = [ + ["collabAgent/started", {}], + ["collabAgent/activity", { activityKind: "started" }], + ["collabAgent/turnStarted", {}], + ["collabAgent/turnCompleted", { turn: { status: "completed" } }], + ["collabAgent/statusChanged", { status: { type: "active", activeFlags: [] } }], + ["collabAgent/tokenUsage", { tokenUsage: { total: { totalTokens: 42 } } }], + ["collabAgent/item", { item: { type: "commandExecution", command: "pwd" } }], + ["collabAgent/closed", {}], + ["collabAgent/metadataUpdated", {}], + ] as const; + + for (const [index, [method, extra]] of cases.entries()) { + yield* runtime.emit({ + id: asEventId(`evt-child-model-${index}`), + kind: "notification", + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:00.000Z", + method, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-1"), + payload: { + agentThreadId: "child-model", + agentPath: "/root/model-check", + model: " gpt-5.6-sol ", + effort: " high ", + ...extra, + }, + }); + } + yield* runtime.emit({ + id: asEventId("evt-child-model-blank"), + kind: "notification", + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:00.000Z", + method: "collabAgent/metadataUpdated", + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-1"), + payload: { + agentThreadId: "child-model", + model: " ", + effort: "", + }, + }); + + const events = Array.from(yield* Fiber.join(eventsFiber)); + NodeAssert.deepStrictEqual( + events.map((event) => event.type), + [ + "task.started", + "task.started", + "task.updated", + "task.updated", + "task.updated", + "task.progress", + "task.progress", + "task.updated", + "task.updated", + "task.updated", + ], + ); + for (const event of events.slice(0, -1)) { + const payload = event.payload as Record; + NodeAssert.equal(payload.model, "gpt-5.6-sol"); + NodeAssert.equal(payload.effort, "high"); + } + + const metadataPayload = events[8]?.payload as Record; + NodeAssert.equal("status" in metadataPayload, false); + const blankMetadataPayload = events[9]?.payload as Record; + NodeAssert.equal("status" in blankMetadataPayload, false); + NodeAssert.equal("model" in blankMetadataPayload, false); + NodeAssert.equal("effort" in blankMetadataPayload, false); + }), + ); + it.effect("does not reactivate an idle child after a parent interaction", () => Effect.gen(function* () { const { adapter, runtime } = yield* startLifecycleRuntime(); diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index 0f7d999662e9..6eaccf9f47a0 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -535,12 +535,16 @@ function mapCollabAgentEvent( // finding: progress rows renamed math_one to its UUID). const knownName = nickname ?? pathLeaf; const title = knownName ?? agentThreadId; + const model = typeof payload.model === "string" ? payload.model.trim() : ""; + const effort = typeof payload.effort === "string" ? payload.effort.trim() : ""; // Identity repeated on every status patch so rows are self-describing when // the start row ages out of activity retention (review finding: a // reconstructed agent had a UUID name and no role/path). - const statusLinkage = { + const linkage = { role, ...(knownName ? { title: knownName } : {}), + ...(model ? { model } : {}), + ...(effort ? { effort } : {}), ...(agentPath ? { agentPath } : {}), timelineBypass: true, } as const; @@ -555,15 +559,21 @@ function mapCollabAgentEvent( taskId, description: title, title, - role, - ...(agentPath ? { agentPath } : {}), + ...linkage, ...(typeof payload.parentThreadId === "string" ? { parentAgentId: payload.parentThreadId } : {}), - timelineBypass: true, }, }, ]; + case "collabAgent/metadataUpdated": + return [ + { + ...base, + type: "task.updated", + payload: { taskId, ...linkage }, + }, + ]; case "collabAgent/activity": { const activityKind = typeof payload.activityKind === "string" ? payload.activityKind : ""; if (activityKind === "interrupted") { @@ -571,7 +581,7 @@ function mapCollabAgentEvent( { ...base, type: "task.updated", - payload: { taskId, status: "interrupted", ...statusLinkage }, + payload: { taskId, status: "interrupted", ...linkage }, }, ]; } @@ -588,9 +598,7 @@ function mapCollabAgentEvent( taskId, description: title, title, - role, - ...(agentPath ? { agentPath } : {}), - timelineBypass: true, + ...linkage, }, }, ]; @@ -604,7 +612,7 @@ function mapCollabAgentEvent( { ...base, type: "task.updated", - payload: { taskId, status: "running", ...statusLinkage }, + payload: { taskId, status: "running", ...linkage }, }, ]; case "collabAgent/turnCompleted": { @@ -624,7 +632,7 @@ function mapCollabAgentEvent( { ...base, type: "task.updated", - payload: { taskId, status, ...statusLinkage }, + payload: { taskId, status, ...linkage }, }, ]; } @@ -640,7 +648,7 @@ function mapCollabAgentEvent( { ...base, type: "task.updated", - payload: { taskId, status: "failed", ...statusLinkage }, + payload: { taskId, status: "failed", ...linkage }, }, ]; } @@ -653,7 +661,7 @@ function mapCollabAgentEvent( { ...base, type: "task.updated", - payload: { taskId, status: waiting ? "waiting" : "running", ...statusLinkage }, + payload: { taskId, status: waiting ? "waiting" : "running", ...linkage }, }, ]; } @@ -662,7 +670,7 @@ function mapCollabAgentEvent( { ...base, type: "task.updated", - payload: { taskId, status: "idle", ...statusLinkage }, + payload: { taskId, status: "idle", ...linkage }, }, ]; } @@ -709,9 +717,8 @@ function mapCollabAgentEvent( payload: { taskId, description: title, - ...(knownName ? { title: knownName } : {}), + ...linkage, typedUsage, - timelineBypass: true, }, }, ]; @@ -741,9 +748,8 @@ function mapCollabAgentEvent( payload: { taskId, description: title, - ...(knownName ? { title: knownName } : {}), + ...linkage, summary, - timelineBypass: true, }, }, ]; @@ -753,7 +759,7 @@ function mapCollabAgentEvent( { ...base, type: "task.updated", - payload: { taskId, status: "interrupted", ...statusLinkage }, + payload: { taskId, status: "interrupted", ...linkage }, }, ]; default: @@ -1816,8 +1822,11 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( }); const sendTurn: CodexAdapterShape["sendTurn"] = Effect.fn("sendTurn")(function* (input) { + // Codex ingests images only. Anything else would be base64-encoded as an + // image and rejected or misread; generic files reach the agent through the + // path line ProviderService puts in the prompt. const codexAttachments = yield* Effect.forEach( - input.attachments ?? [], + (input.attachments ?? []).filter((attachment) => attachment.type === "image"), (attachment) => resolveAttachment(input, attachment), { concurrency: 1 }, ); diff --git a/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts b/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts index 5af06efb71dc..5bc5940fe539 100644 --- a/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts +++ b/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts @@ -81,10 +81,319 @@ function buildScript() { }; } +function capturedStartedActivity(childId = CHILD_A) { + const captured = wireFixture.notifications.find((entry) => { + const item = (entry.params as { item?: { type?: string; kind?: string } }).item; + return item?.type === "subAgentActivity" && item.kind === "started"; + }); + assert.isDefined(captured); + return { + ...captured, + params: { + ...captured.params, + item: { + ...captured.params.item, + agentThreadId: childId, + agentPath: "/root/model-check", + }, + }, + }; +} + +function capturedSpawnedThread(childId = CHILD_A) { + const captured = wireFixture.notifications.find((entry) => entry.method === "thread/started"); + assert.isDefined(captured); + return { + ...captured, + params: { + thread: { + ...captured.params.thread, + id: childId, + sessionId: childId, + parentThreadId: ROOT, + agentNickname: "model-check", + agentRole: "verifier", + source: { + subAgent: { + thread_spawn: { + agent_nickname: "model-check", + agent_path: "/root/model-check", + agent_role: "verifier", + depth: 1, + parent_thread_id: ROOT, + }, + }, + }, + }, + }, + }; +} + +function childSettings(threadId: string, model: string, effort: string) { + return { + method: "thread/settings/updated", + params: { + threadId, + threadSettings: { + approvalPolicy: "on-request", + approvalsReviewer: "auto_review", + collaborationMode: { mode: "default", settings: { model } }, + cwd: "/workspace/repo", + effort, + model, + modelProvider: "openai", + sandboxPolicy: { type: "dangerFullAccess" }, + }, + }, + }; +} + +function readRecordedRequests() { + return NodeFS.readFileSync(`${scriptPath}.requests`, "utf8") + .trim() + .split("\n") + .filter((line) => line.length > 0) + .map((line) => JSON.parse(line) as { method: string; params: Record }); +} + const scriptPath = NodePath.join(import.meta.dirname, "../testFixtures/.collab-script.json"); const peerPath = NodePath.join(import.meta.dirname, "../testFixtures/codexCollabMockPeer.sh"); describe("CodexSessionRuntime collab integration", () => { + it.effect("looks up child model metadata once after activity registration", () => + Effect.gen(function* () { + const script = { + rootThreadId: ROOT, + recordRequests: true, + notifications: [ + capturedStartedActivity(), + capturedStartedActivity(), + { + ...capturedStartedActivity(CHILD_B), + params: { + ...capturedStartedActivity(CHILD_B).params, + item: { ...capturedStartedActivity(CHILD_B).params.item, kind: "interacted" }, + }, + }, + { method: "thread/closed", params: { threadId: CHILD_B } }, + capturedSpawnedThread(ROOT), + ], + childResumeSnapshots: { + [CHILD_A]: { model: "gpt-5.6-luna", reasoningEffort: "low" }, + }, + }; + // @effect-diagnostics-next-line preferSchemaOverJson:off + NodeFS.writeFileSync(scriptPath, JSON.stringify(script), "utf8"); + NodeFS.rmSync(`${scriptPath}.requests`, { force: true }); + yield* Effect.addFinalizer(() => + Effect.sync(() => { + NodeFS.rmSync(scriptPath, { force: true }); + NodeFS.rmSync(`${scriptPath}.requests`, { force: true }); + }), + ); + + const runtime = yield* makeCodexSessionRuntime({ + threadId: ThreadId.make("thread-collab-model-activity"), + binaryPath: peerPath, + cwd: "/tmp", + runtimeMode: "full-access", + environment: { ...process.env, T3_CODEX_COLLAB_SCRIPT: scriptPath }, + }); + const metadataFiber = yield* runtime.events.pipe( + Stream.filter( + (event) => + event.method === "collabAgent/metadataUpdated" && + (event.payload as { agentThreadId?: string }).agentThreadId === CHILD_A, + ), + Stream.take(1), + Stream.runCollect, + Effect.forkScoped, + ); + + const session = yield* runtime.start(); + assert.equal(session.model, "gpt-5.6-sol"); + yield* runtime.sendTurn({ input: "start one child" }); + const metadataEvents = Array.from(yield* Fiber.join(metadataFiber)); + assert.deepInclude(metadataEvents[0]?.payload, { + agentThreadId: CHILD_A, + model: "gpt-5.6-luna", + effort: "low", + }); + assert.deepEqual(readRecordedRequests(), [ + { + method: "thread/resume", + params: { threadId: CHILD_A, excludeTurns: true }, + }, + ]); + + yield* runtime.close; + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("keeps child settings and reroutes newer than the resume snapshot", () => + Effect.gen(function* () { + const statusChanged = wireFixture.notifications.find( + (entry) => + entry.method === "thread/status/changed" && + (entry.params as { threadId?: string }).threadId === CHILD_A, + ); + assert.isDefined(statusChanged); + const script = { + rootThreadId: ROOT, + recordRequests: true, + notifications: [ + childSettings(CHILD_A, "child-before", "medium"), + capturedSpawnedThread(), + childSettings(CHILD_A, "child-after", "high"), + { + method: "model/rerouted", + params: { + threadId: CHILD_A, + turnId: `${CHILD_A}-turn`, + fromModel: "child-after", + toModel: "child-rerouted", + reason: "highRiskCyberActivity", + }, + }, + { + method: "model/rerouted", + params: { + threadId: ROOT, + turnId: `${ROOT}-turn`, + fromModel: "gpt-5.6-sol", + toModel: "root-rerouted", + reason: "highRiskCyberActivity", + }, + }, + ], + childResumeSnapshots: { + [CHILD_A]: { + model: "stale-snapshot", + reasoningEffort: "low", + notifications: [statusChanged], + }, + }, + }; + // @effect-diagnostics-next-line preferSchemaOverJson:off + NodeFS.writeFileSync(scriptPath, JSON.stringify(script), "utf8"); + NodeFS.rmSync(`${scriptPath}.requests`, { force: true }); + yield* Effect.addFinalizer(() => + Effect.sync(() => { + NodeFS.rmSync(scriptPath, { force: true }); + NodeFS.rmSync(`${scriptPath}.requests`, { force: true }); + }), + ); + + const runtime = yield* makeCodexSessionRuntime({ + threadId: ThreadId.make("thread-collab-model-spawn"), + binaryPath: peerPath, + cwd: "/tmp", + runtimeMode: "full-access", + environment: { ...process.env, T3_CODEX_COLLAB_SCRIPT: scriptPath }, + }); + const eventsFiber = yield* runtime.events.pipe( + Stream.takeUntil( + (event) => + event.method === "collabAgent/statusChanged" && + (event.payload as { agentThreadId?: string }).agentThreadId === CHILD_A, + ), + Stream.runCollect, + Effect.forkScoped, + ); + + yield* runtime.start(); + yield* runtime.sendTurn({ input: "start one spawned child" }); + const events = Array.from(yield* Fiber.join(eventsFiber)); + const started = events.find((event) => event.method === "collabAgent/started"); + assert.deepInclude(started?.payload, { + agentThreadId: CHILD_A, + model: "child-before", + effort: "medium", + }); + const childStatus = events.find((event) => event.method === "collabAgent/statusChanged"); + assert.deepInclude(childStatus?.payload, { + agentThreadId: CHILD_A, + model: "child-rerouted", + effort: "high", + }); + assert.isTrue( + events.some( + (event) => + event.method === "model/rerouted" && + (event.payload as { threadId?: string }).threadId === ROOT, + ), + "the root reroute must stay on the parent path", + ); + assert.isFalse( + events.some( + (event) => + (event.method === "thread/settings/updated" || event.method === "model/rerouted") && + (event.payload as { threadId?: string }).threadId === CHILD_A, + ), + "child metadata notifications must not leak to the parent path", + ); + assert.equal(readRecordedRequests().length, 1); + + yield* runtime.close; + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("does not delay the parent turn when the child lookup fails", () => + Effect.gen(function* () { + yield* Effect.addFinalizer(() => + Effect.sync(() => { + NodeFS.rmSync(scriptPath, { force: true }); + NodeFS.rmSync(`${scriptPath}.requests`, { force: true }); + }), + ); + for (const [name, childSnapshot] of [ + ["hang", { hang: true }], + ["error", { error: "child unavailable" }], + ] as const) { + yield* Effect.gen(function* () { + const marker = `lookup-${name}`; + const script = { + rootThreadId: ROOT, + recordRequests: true, + resumeRequestMarker: marker, + notifications: [capturedStartedActivity()], + childResumeSnapshots: { [CHILD_A]: childSnapshot }, + }; + // @effect-diagnostics-next-line preferSchemaOverJson:off + NodeFS.writeFileSync(scriptPath, JSON.stringify(script), "utf8"); + NodeFS.rmSync(`${scriptPath}.requests`, { force: true }); + + const runtime = yield* makeCodexSessionRuntime({ + threadId: ThreadId.make(`thread-collab-model-${name}`), + binaryPath: peerPath, + cwd: "/tmp", + runtimeMode: "full-access", + environment: { ...process.env, T3_CODEX_COLLAB_SCRIPT: scriptPath }, + }); + const eventsFiber = yield* runtime.events.pipe( + Stream.takeUntil( + (event) => + event.method === "serverRequest/resolved" && + (event.payload as { requestId?: string }).requestId === marker, + ), + Stream.runCollect, + Effect.forkScoped, + ); + + yield* runtime.start(); + yield* runtime.sendTurn({ input: "finish without child metadata" }); + const events = Array.from(yield* Fiber.join(eventsFiber)); + assert.isTrue(events.some((event) => event.method === "turn/completed")); + assert.equal(readRecordedRequests().length, 1); + + yield* runtime.close; + NodeFS.rmSync(scriptPath, { force: true }); + NodeFS.rmSync(`${scriptPath}.requests`, { force: true }); + }).pipe(Effect.scoped); + } + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + it.effect("replays the captured fan-out into synthetic agent events without child leaks", () => Effect.gen(function* () { // @effect-diagnostics-next-line preferSchemaOverJson:off diff --git a/apps/server/src/provider/Layers/CodexCollabWire.test.ts b/apps/server/src/provider/Layers/CodexCollabWire.test.ts index 50e5e819d1f0..363c1560ca54 100644 --- a/apps/server/src/provider/Layers/CodexCollabWire.test.ts +++ b/apps/server/src/provider/Layers/CodexCollabWire.test.ts @@ -119,6 +119,8 @@ describe("routeCodexChildNotification", () => { "turn/completed", "thread/status/changed", "thread/tokenUsage/updated", + "thread/settings/updated", + "model/rerouted", "item/started", "item/completed", "thread/closed", @@ -159,6 +161,8 @@ describe("routeCodexChildNotification", () => { "turn/completed", "turn/plan/updated", "item/plan/delta", + "thread/settings/updated", + "model/rerouted", ]) { assert.notEqual( routeCodexChildNotification(method), diff --git a/apps/server/src/provider/Layers/CodexProvider.test.ts b/apps/server/src/provider/Layers/CodexProvider.test.ts index 7469818dcefd..2aeebdb2ccd8 100644 --- a/apps/server/src/provider/Layers/CodexProvider.test.ts +++ b/apps/server/src/provider/Layers/CodexProvider.test.ts @@ -1,31 +1,6 @@ import { assert, it } from "@effect/vitest"; -import { - applyPreferredCodexDefaultModel, - isLegacyCodexModel, - mapCodexModelCapabilities, -} from "./CodexProvider.ts"; - -it("keeps current Codex models out of legacy models", () => { - assert.deepStrictEqual( - [ - "gpt-5.6-luna", - "gpt-5.6-terra", - "gpt-5.6-sol", - "gpt-daybreak-blue-latest", - "gpt-daybreak-red-latest", - "gpt-5.4", - ].map((model) => [model, isLegacyCodexModel(model)]), - [ - ["gpt-5.6-luna", false], - ["gpt-5.6-terra", false], - ["gpt-5.6-sol", false], - ["gpt-daybreak-blue-latest", false], - ["gpt-daybreak-red-latest", false], - ["gpt-5.4", true], - ], - ); -}); +import { applyPreferredCodexDefaultModel, mapCodexModelCapabilities } from "./CodexProvider.ts"; it("maps current Codex model capability fields", () => { const capabilities = mapCodexModelCapabilities({ diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index 93730046dc49..776a8e00e1cc 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -62,17 +62,6 @@ const REASONING_EFFORT_LABELS: Readonly> = { }; const DEFAULT_SERVICE_TIER_ID = "default"; -const CURRENT_CODEX_MODELS = new Set([ - "gpt-5.6-luna", - "gpt-5.6-terra", - "gpt-5.6-sol", - "gpt-daybreak-blue-latest", - "gpt-daybreak-red-latest", -]); - -export function isLegacyCodexModel(model: string): boolean { - return !CURRENT_CODEX_MODELS.has(model); -} function reasoningEffortLabel(reasoningEffort: string): string { return REASONING_EFFORT_LABELS[reasoningEffort] ?? reasoningEffort; @@ -97,13 +86,18 @@ function codexAccountAuthLabel(account: CodexSchema.V2GetAccountResponse["accoun return "ChatGPT Pro 5x Subscription"; case "team": return "ChatGPT Team Subscription"; + case "self_serve_business_prolite": case "self_serve_business_usage_based": case "business": return "ChatGPT Business Subscription"; + case "ent26": + case "enterprise_cbp_automation": case "enterprise_cbp_usage_based": case "enterprise": return "ChatGPT Enterprise Subscription"; case "edu": + case "edu_plus": + case "edu_pro": return "ChatGPT Edu Subscription"; case "unknown": return "ChatGPT Subscription"; @@ -201,7 +195,6 @@ function parseCodexModelListResponse( name: toDisplayName(model), isCustom: false, ...(model.isDefault ? { isDefault: true } : {}), - ...(isLegacyCodexModel(model.model) ? { isLegacy: true } : {}), capabilities: mapCodexModelCapabilities(model), })); } diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index b34067b7fb90..d83489763f5c 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -137,6 +137,12 @@ const CodexTurnStartParamsWithCollaborationMode = EffectCodexSchema.V2TurnStartP const decodeCodexTurnStartParamsWithCollaborationMode = Schema.decodeUnknownEffect( CodexTurnStartParamsWithCollaborationMode, ); +const CodexChildResumeMetadata = Schema.Struct({ + thread: Schema.Struct({ id: Schema.String }), + model: Schema.String, + reasoningEffort: Schema.optionalKey(Schema.NullOr(Schema.String)), +}); +const decodeCodexChildResumeMetadata = Schema.decodeUnknownEffect(CodexChildResumeMetadata); export type CodexTurnStartParamsWithCollaborationMode = typeof CodexTurnStartParamsWithCollaborationMode.Type; @@ -731,7 +737,9 @@ function readNotificationThreadId(notification: CodexServerNotification): string case "thread/unarchived": case "thread/closed": case "thread/name/updated": + case "thread/settings/updated": case "thread/tokenUsage/updated": + case "model/rerouted": case "turn/started": case "hook/started": case "turn/completed": @@ -904,6 +912,35 @@ interface CollabChildAgentState { readonly spawnTurnId: TurnId | undefined; } +interface CollabChildMetadataState { + readonly model: string | undefined; + readonly effort: string | undefined; + readonly lookupStarted: boolean; + readonly closed: boolean; +} + +function collabChildIdentity( + child: CollabChildAgentState, + metadata: CollabChildMetadataState | undefined, +) { + return { + agentThreadId: child.agentThreadId, + ...(child.nickname ? { nickname: child.nickname } : {}), + ...(child.role ? { role: child.role } : {}), + ...(child.agentPath ? { agentPath: child.agentPath } : {}), + ...(metadata?.model ? { model: metadata.model } : {}), + ...(metadata?.effort ? { effort: metadata.effort } : {}), + }; +} + +function nonEmptyMetadataValue(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + function readThreadSpawnSource(thread: { readonly source: unknown }): | { nickname: string | undefined; @@ -969,7 +1006,9 @@ function shouldSuppressChildConversationNotification( method === "thread/closed" || method === "thread/compacted" || method === "thread/name/updated" || + method === "thread/settings/updated" || method === "thread/tokenUsage/updated" || + method === "model/rerouted" || method === "turn/started" || method === "turn/completed" || method === "turn/plan/updated" || @@ -1000,6 +1039,8 @@ const CHILD_AGENT_EVENT_METHODS: ReadonlySet = new Set([ "turn/completed", "thread/status/changed", "thread/tokenUsage/updated", + "thread/settings/updated", + "model/rerouted", "item/started", "item/completed", "thread/closed", @@ -1018,7 +1059,6 @@ const CHILD_CHATTER_METHODS: ReadonlySet = new Set([ "turn/plan/updated", "turn/diff/updated", "thread/name/updated", - "thread/settings/updated", "rawResponseItem/completed", // Child-owned thread lifecycle: the parent adapter maps these onto the // PARENT thread (archived/compacted state), so a child compacting would @@ -1126,6 +1166,7 @@ export const makeCodexSessionRuntime = ( const pendingUserInputsRef = yield* Ref.make(new Map()); const collabReceiverTurnsRef = yield* Ref.make(new Map()); const collabChildAgentsRef = yield* Ref.make(new Map()); + const collabChildMetadataRef = yield* Ref.make(new Map()); /** Child provider-thread id → its currently running provider turn id. */ const collabChildLiveTurnsRef = yield* Ref.make(new Map()); const suppressMemoryConsolidationNotification = makeMemoryConsolidationNotificationFilter(); @@ -1221,6 +1262,133 @@ export const makeCodexSessionRuntime = ( message, }); + const updateCollabChildMetadata = ( + agentThreadId: string, + update: { readonly model?: string; readonly effort?: string }, + overwriteKnown: boolean, + ) => + Ref.modify(collabChildMetadataRef, (current) => { + const previous = current.get(agentThreadId) ?? { + model: undefined, + effort: undefined, + lookupStarted: false, + closed: false, + }; + const model = + update.model && (overwriteKnown || !previous.model) ? update.model : previous.model; + const effort = + update.effort && (overwriteKnown || !previous.effort) ? update.effort : previous.effort; + const changed = model !== previous.model || effort !== previous.effort; + if (!changed) { + return [false, current] as const; + } + const next = new Map(current); + next.set(agentThreadId, { ...previous, model, effort }); + return [true, next] as const; + }); + + const markCollabChildClosed = (agentThreadId: string) => + Ref.update(collabChildMetadataRef, (current) => { + const previous = current.get(agentThreadId) ?? { + model: undefined, + effort: undefined, + lookupStarted: false, + closed: false, + }; + if (previous.closed) { + return current; + } + const next = new Map(current); + next.set(agentThreadId, { ...previous, closed: true }); + return next; + }); + + const markCollabChildOpen = (agentThreadId: string) => + Ref.update(collabChildMetadataRef, (current) => { + const previous = current.get(agentThreadId); + if (!previous?.closed) { + return current; + } + const next = new Map(current); + next.set(agentThreadId, { ...previous, closed: false }); + return next; + }); + + const emitCollabChildMetadataUpdated = Effect.fn( + "CodexSessionRuntime.emitCollabChildMetadataUpdated", + )(function* (agentThreadId: string) { + const child = (yield* Ref.get(collabChildAgentsRef)).get(agentThreadId); + const metadata = (yield* Ref.get(collabChildMetadataRef)).get(agentThreadId); + if (!child || metadata?.closed) { + return; + } + yield* emitEvent({ + kind: "notification", + threadId: options.threadId, + ...(child.spawnTurnId ? { turnId: child.spawnTurnId } : {}), + method: "collabAgent/metadataUpdated", + payload: collabChildIdentity(child, metadata), + }); + }); + + const startCollabChildMetadataLookup = Effect.fn( + "CodexSessionRuntime.startCollabChildMetadataLookup", + )(function* (agentThreadId: string) { + const shouldStart = yield* Ref.modify(collabChildMetadataRef, (current) => { + const previous = current.get(agentThreadId) ?? { + model: undefined, + effort: undefined, + lookupStarted: false, + closed: false, + }; + if (previous.lookupStarted || previous.closed) { + return [false, current] as const; + } + const next = new Map(current); + next.set(agentThreadId, { ...previous, lookupStarted: true }); + return [true, next] as const; + }); + if (!shouldStart) { + return; + } + + // The child is already loaded. This rejoins it without starting a turn, + // and excludeTurns avoids loading or replaying its history. + yield* client.raw + .request("thread/resume", { threadId: agentThreadId, excludeTurns: true }) + .pipe( + Effect.flatMap(decodeCodexChildResumeMetadata), + Effect.timeout("5 seconds"), + Effect.flatMap((response) => + Effect.gen(function* () { + if (response.thread.id !== agentThreadId) { + return; + } + const child = (yield* Ref.get(collabChildAgentsRef)).get(agentThreadId); + const metadata = (yield* Ref.get(collabChildMetadataRef)).get(agentThreadId); + if (!child || metadata?.closed) { + return; + } + const model = nonEmptyMetadataValue(response.model); + const effort = nonEmptyMetadataValue(response.reasoningEffort); + const changed = yield* updateCollabChildMetadata( + agentThreadId, + { + ...(model ? { model } : {}), + ...(effort ? { effort } : {}), + }, + false, + ); + if (changed) { + yield* emitCollabChildMetadataUpdated(agentThreadId); + } + }), + ), + Effect.catch(() => Effect.void), + Effect.forkIn(runtimeScope), + ); + }); + const settlePendingApprovals = (decision: ProviderApprovalDecision) => Ref.get(pendingApprovalsRef).pipe( Effect.flatMap((pendingApprovals) => @@ -1261,6 +1429,10 @@ export const makeCodexSessionRuntime = ( if (!spawn) { return false; } + const rootProviderThreadId = currentProviderThreadId(yield* Ref.get(sessionRef)); + if (thread.id === rootProviderThreadId) { + return false; + } // Merge with any subAgentActivity registration that got here // first. spawnTurnId is REGISTRATION-time-only on both paths: for // an already-known child we keep its value (set or unset) — a @@ -1287,20 +1459,19 @@ export const makeCodexSessionRuntime = ( next.set(thread.id, state); return next; }); + const metadata = (yield* Ref.get(collabChildMetadataRef)).get(thread.id); yield* emitEvent({ kind: "notification", threadId: options.threadId, method: "collabAgent/started", ...(state.spawnTurnId ? { turnId: state.spawnTurnId } : {}), payload: { - agentThreadId: state.agentThreadId, - ...(state.nickname ? { nickname: state.nickname } : {}), - ...(state.role ? { role: state.role } : {}), - ...(state.agentPath ? { agentPath: state.agentPath } : {}), + ...collabChildIdentity(state, metadata), ...(state.depth !== undefined ? { depth: state.depth } : {}), ...(state.parentThreadId ? { parentThreadId: state.parentThreadId } : {}), }, }); + yield* startCollabChildMetadataLookup(thread.id); return true; } @@ -1350,17 +1521,22 @@ export const makeCodexSessionRuntime = ( return next; }); const registeredChild = (yield* Ref.get(collabChildAgentsRef)).get(item.agentThreadId); + const metadata = (yield* Ref.get(collabChildMetadataRef)).get(item.agentThreadId); yield* emitEvent({ kind: "notification", threadId: options.threadId, method: "collabAgent/activity", ...(registeredChild?.spawnTurnId ? { turnId: registeredChild.spawnTurnId } : {}), payload: { - agentThreadId: item.agentThreadId, - agentPath: item.agentPath, + ...(registeredChild + ? collabChildIdentity(registeredChild, metadata) + : { agentThreadId: item.agentThreadId, agentPath: item.agentPath }), activityKind: item.kind, }, }); + if (item.kind === "started") { + yield* startCollabChildMetadataLookup(item.agentThreadId); + } return true; } @@ -1376,19 +1552,45 @@ export const makeCodexSessionRuntime = ( if (providerConversationId === interceptRootId) { return false; } + + if ( + interceptRootId !== undefined && + (notification.method === "thread/settings/updated" || + notification.method === "model/rerouted") + ) { + const model = nonEmptyMetadataValue( + notification.method === "thread/settings/updated" + ? notification.params.threadSettings.model + : notification.params.toModel, + ); + const effort = + notification.method === "thread/settings/updated" + ? nonEmptyMetadataValue(notification.params.threadSettings.effort) + : undefined; + const changed = yield* updateCollabChildMetadata( + providerConversationId, + { + ...(model ? { model } : {}), + ...(effort ? { effort } : {}), + }, + true, + ); + if (changed && (yield* Ref.get(collabChildAgentsRef)).has(providerConversationId)) { + yield* emitCollabChildMetadataUpdated(providerConversationId); + } + return true; + } + const children = yield* Ref.get(collabChildAgentsRef); const child = children.get(providerConversationId); if (!child) { return false; } - const childIdentity = { - agentThreadId: child.agentThreadId, - ...(child.nickname ? { nickname: child.nickname } : {}), - ...(child.role ? { role: child.role } : {}), - ...(child.agentPath ? { agentPath: child.agentPath } : {}), - }; + const metadata = (yield* Ref.get(collabChildMetadataRef)).get(child.agentThreadId); + const childIdentity = collabChildIdentity(child, metadata); switch (notification.method) { case "turn/started": { + yield* markCollabChildOpen(child.agentThreadId); const childTurnId = typeof (notification.params as { turn?: { id?: unknown } }).turn?.id === "string" ? ((notification.params as { turn: { id: string } }).turn.id as string) @@ -1472,6 +1674,7 @@ export const makeCodexSessionRuntime = ( next.delete(child.agentThreadId); return next; }); + yield* markCollabChildClosed(child.agentThreadId); yield* emitEvent({ kind: "notification", threadId: options.threadId, diff --git a/apps/server/src/provider/Layers/CursorAdapter.ts b/apps/server/src/provider/Layers/CursorAdapter.ts index 30c173d8fae8..818fe567b9b3 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -972,6 +972,11 @@ export function makeCursorAdapter( } if (input.attachments && input.attachments.length > 0) { for (const attachment of input.attachments) { + // Cursor ingests images only. Generic files reach the agent + // through the path line ProviderService puts in the prompt. + if (attachment.type !== "image") { + continue; + } const attachmentPath = resolveAttachmentPath({ attachmentsDir: serverConfig.attachmentsDir, attachment, diff --git a/apps/server/src/provider/Layers/EventNdjsonLogger.test.ts b/apps/server/src/provider/Layers/EventNdjsonLogger.test.ts index f6fb557e4b43..c072e6e5148f 100644 --- a/apps/server/src/provider/Layers/EventNdjsonLogger.test.ts +++ b/apps/server/src/provider/Layers/EventNdjsonLogger.test.ts @@ -286,7 +286,7 @@ describe("EventNdjsonLogger", () => { }), ); - it.effect("drops transient canonical events before serialization", () => + it.effect("drops transient provider events before serialization", () => Effect.gen(function* () { const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); const basePath = NodePath.join(tempDir, "events.log"); @@ -302,6 +302,46 @@ describe("EventNdjsonLogger", () => { yield* canonical.write(circularDelta, threadId); yield* canonical.write({ type: "item.completed", id: "final" }, threadId); yield* native.write({ type: "content.delta", id: "native-delta" }, threadId); + yield* native.write( + { method: "item/agentMessage/delta", payload: circularDelta }, + threadId, + ); + yield* native.write( + { method: "thread/realtime/outputAudio/delta", payload: circularDelta }, + threadId, + ); + yield* native.write( + { method: "thread/realtime/transcript/delta", payload: circularDelta }, + threadId, + ); + yield* native.write( + { + event: { + method: "claude/stream_event/content_block_delta/text_delta", + payload: circularDelta, + }, + }, + threadId, + ); + yield* native.write( + { + event: { + method: "session/update", + payload: { update: { sessionUpdate: "agent_message_chunk" } }, + }, + }, + threadId, + ); + yield* native.write( + { + event: { + type: "message.part.updated", + payload: { properties: { part: { type: "text" } } }, + }, + }, + threadId, + ); + yield* native.write({ type: "turn.completed", id: "native-final" }, threadId); yield* store.close(); const lines = NodeFS.readFileSync(ownedLogPath(basePath, "thread-filtered"), "utf8") @@ -313,7 +353,7 @@ describe("EventNdjsonLogger", () => { lines.map(({ stream, payload }) => ({ stream, payload })), [ { stream: "CANON", payload: '{"type":"item.completed","id":"final"}' }, - { stream: "NTIVE", payload: '{"type":"content.delta","id":"native-delta"}' }, + { stream: "NTIVE", payload: '{"type":"turn.completed","id":"native-final"}' }, ], ); } finally { diff --git a/apps/server/src/provider/Layers/EventNdjsonLogger.ts b/apps/server/src/provider/Layers/EventNdjsonLogger.ts index e07121ea76c1..241eddb3b9cb 100644 --- a/apps/server/src/provider/Layers/EventNdjsonLogger.ts +++ b/apps/server/src/provider/Layers/EventNdjsonLogger.ts @@ -45,6 +45,17 @@ const transientCanonicalEventTypes = new Set([ "tool.progress", "turn.proposed.delta", ]); +const transientNativeMethods = new Set([ + "item/agentMessage/delta", + "item/commandExecution/outputDelta", + "item/fileChange/outputDelta", + "item/plan/delta", + "item/reasoning/summaryTextDelta", + "item/reasoning/textDelta", + "thread/realtime/outputAudio/delta", + "thread/realtime/transcript/delta", +]); +const transientAcpUpdates = new Set(["agent_message_chunk", "agent_thought_chunk"]); export type EventNdjsonStream = "native" | "canonical" | "orchestration"; @@ -126,7 +137,7 @@ export interface PendingRecord { } interface StoreState { - readonly pending: ReadonlyArray; + readonly pending: Array; readonly pendingBytes: number; readonly sinks: ReadonlyMap; readonly flushScheduled: boolean; @@ -178,12 +189,50 @@ function providerLogPath(directory: string, prefix: string, threadSegment: strin } function shouldPersist(stream: EventNdjsonStream, event: unknown): boolean { - if (stream !== "canonical" || typeof event !== "object" || event === null) { + if (stream === "orchestration" || typeof event !== "object" || event === null) { return true; } try { const type = Reflect.get(event, "type"); - return typeof type !== "string" || !transientCanonicalEventTypes.has(type); + if (typeof type === "string" && transientCanonicalEventTypes.has(type)) { + return false; + } + if (stream !== "native") return true; + + const nested = Reflect.get(event, "event"); + const nativeEvent = typeof nested === "object" && nested !== null ? nested : event; + const method = Reflect.get(nativeEvent, "method"); + if ( + typeof method === "string" && + (transientNativeMethods.has(method) || + method.startsWith("claude/stream_event/content_block_delta/")) + ) { + return false; + } + + const nativeType = Reflect.get(nativeEvent, "type"); + if (nativeType === "message.part.delta") return false; + + const payload = Reflect.get(nativeEvent, "payload"); + if (typeof payload !== "object" || payload === null) return true; + + if (method === "session/update") { + const update = Reflect.get(payload, "update"); + if (typeof update !== "object" || update === null) return true; + const updateType = Reflect.get(update, "sessionUpdate"); + return typeof updateType !== "string" || !transientAcpUpdates.has(updateType); + } + + if (nativeType === "message.part.updated") { + const properties = Reflect.get(payload, "properties"); + if (typeof properties !== "object" || properties === null) return true; + const part = Reflect.get(properties, "part"); + if (typeof part !== "object" || part === null) return true; + const partType = Reflect.get(part, "type"); + return partType !== "text" && partType !== "reasoning"; + } + + return true; } catch { return true; } @@ -566,10 +615,8 @@ export const makeEventNdjsonLogStore = Effect.fnUntraced(function* ( if (state.closed) { return Effect.succeed([{ flush: false }, state] as const); } - const pending = [ - ...state.pending, - { stream, threadSegment: resolveThreadSegment(threadId), line, bytes }, - ]; + const pending = state.pending; + pending.push({ stream, threadSegment: resolveThreadSegment(threadId), line, bytes }); const pendingBytes = state.pendingBytes + bytes; const flush = resolved.batchWindowMs === 0 || diff --git a/apps/server/src/provider/Layers/GrokAdapter.test.ts b/apps/server/src/provider/Layers/GrokAdapter.test.ts index 6cb71660a74c..eeee17d9ac6a 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.test.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.test.ts @@ -26,7 +26,13 @@ import { } from "@t3tools/contracts"; import { ServerConfig } from "../../config.ts"; -import { grokPromptSettlementBelongsToContext, makeGrokAdapter } from "./GrokAdapter.ts"; +import { + grokPromptSettlementBelongsToContext, + isGrokEnterPlanModeToolCall, + makeGrokAdapter, + nextGrokPlanModeActive, + selectGrokPermissionOptionId, +} from "./GrokAdapter.ts"; const decodeGrokSettings = Schema.decodeSync(GrokSettings); const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); @@ -89,6 +95,90 @@ const grokAdapterTestLayer = ServerConfig.layerTest(process.cwd(), { const makeTestAdapter = (binaryPath: string, options?: Parameters[1]) => makeGrokAdapter(decodeGrokSettings({ binaryPath }), options).pipe(Effect.orDie); +it("detects enter_plan_mode tool calls from title and rawInput", () => { + assert.isTrue( + isGrokEnterPlanModeToolCall({ + title: "enter_plan_mode", + data: { toolCallId: "1" }, + }), + ); + assert.isTrue( + isGrokEnterPlanModeToolCall({ + title: "Plan mode entered", + data: { toolCallId: "1", rawInput: { variant: "EnterPlanMode" } }, + }), + ); + assert.isFalse( + isGrokEnterPlanModeToolCall({ + title: "write", + data: { toolCallId: "1", rawInput: { file_path: "/tmp/x", content: "y" } }, + }), + ); +}); + +it("only sets planModeActive after a successful enter_plan_mode", () => { + const enter = { + title: "enter_plan_mode", + data: { toolCallId: "1" }, + }; + assert.isFalse(nextGrokPlanModeActive(false, { ...enter, status: "pending" })); + assert.isTrue(nextGrokPlanModeActive(false, { ...enter, status: "inProgress" })); + assert.isTrue(nextGrokPlanModeActive(false, { ...enter, status: "completed" })); + assert.isFalse(nextGrokPlanModeActive(false, { ...enter, status: "failed" })); + assert.isFalse(nextGrokPlanModeActive(true, { ...enter, status: "failed" })); + assert.isTrue( + nextGrokPlanModeActive(true, { + title: "write", + status: "completed", + data: { toolCallId: "2" }, + }), + ); +}); + +function grokPermissionRequest( + options: ReadonlyArray<{ + readonly optionId: string; + readonly kind: "allow_once" | "allow_always" | "reject_once" | "reject_always"; + }>, +) { + return { + sessionId: "mock-session-1", + toolCall: { + toolCallId: "tool-call-1", + title: "cat package.json", + kind: "execute" as const, + status: "pending" as const, + }, + options: options.map((option) => ({ + optionId: option.optionId, + name: option.kind, + kind: option.kind, + })), + }; +} + +it("maps Always allow to allow_once when Grok omits allow_always", () => { + const request = grokPermissionRequest([ + { optionId: "allow-once", kind: "allow_once" }, + { optionId: "reject-once", kind: "reject_once" }, + ]); + + assert.equal(selectGrokPermissionOptionId(request, "acceptForSession"), "allow-once"); + assert.equal(selectGrokPermissionOptionId(request, "accept"), "allow-once"); + assert.equal(selectGrokPermissionOptionId(request, "decline"), "reject-once"); +}); + +it("prefers allow_always when Grok offers it", () => { + const request = grokPermissionRequest([ + { optionId: "allow-once", kind: "allow_once" }, + { optionId: "allow-always", kind: "allow_always" }, + { optionId: "reject-once", kind: "reject_once" }, + ]); + + assert.equal(selectGrokPermissionOptionId(request, "acceptForSession"), "allow-always"); + assert.equal(selectGrokPermissionOptionId(request, "accept"), "allow-once"); +}); + it("requires a settlement to match the live Grok turn", () => { const staleTurnId = TurnId.make("stale-turn"); const replacementTurnId = TurnId.make("replacement-turn"); @@ -418,6 +508,362 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { }), ); + it.effect("does not time out a Grok turn before ACP emits progress", () => + Effect.gen(function* () { + const threadId = ThreadId.make("grok-watchdog-silent-turn"); + const wrapperPath = yield* Effect.promise(() => + makeMockGrokWrapper({ + T3_ACP_HANG_PROMPT_FOREVER: "1", + }), + ); + const adapter = yield* makeTestAdapter(wrapperPath, { + turnInactivityTimeoutMs: 1_000, + }); + const runtimeEvents: ProviderRuntimeEvent[] = []; + const turnStarted = yield* Deferred.make(); + const turnCompleted = + yield* Deferred.make>(); + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.gen(function* () { + if (String(event.threadId) !== String(threadId)) { + return; + } + runtimeEvents.push(event); + if (event.type === "turn.started") { + yield* Deferred.succeed(turnStarted, undefined).pipe(Effect.ignore); + } + if (event.type === "turn.completed") { + yield* Deferred.succeed(turnCompleted, event).pipe(Effect.ignore); + } + }), + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("grok"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + + const sendTurnFiber = yield* adapter + .sendTurn({ threadId, input: "silence forever", attachments: [] }) + .pipe(Effect.forkChild); + yield* Deferred.await(turnStarted); + + yield* TestClock.adjust("5 seconds"); + yield* Effect.yieldNow; + const steerSendTurnFiber = yield* adapter + .sendTurn({ threadId, input: "keep reasoning", attachments: [] }) + .pipe(Effect.forkChild); + for (let yieldAttempt = 0; yieldAttempt < 12; yieldAttempt += 1) { + yield* Effect.yieldNow; + } + yield* TestClock.adjust("5 seconds"); + yield* Effect.yieldNow; + assert.lengthOf( + runtimeEvents.filter( + (event) => event.type === "turn.completed" && String(event.threadId) === String(threadId), + ), + 0, + ); + + yield* adapter.interruptTurn(threadId); + const completed = yield* Deferred.await(turnCompleted).pipe( + Effect.timeout("2 seconds"), + TestClock.withLive, + ); + yield* Fiber.join(sendTurnFiber); + yield* Fiber.interrupt(steerSendTurnFiber); + + assert.equal(completed.payload.state, "cancelled"); + const session = (yield* adapter.listSessions()).find( + (candidate) => candidate.threadId === threadId, + ); + assert.equal(session?.status, "ready"); + assert.isUndefined(session?.activeTurnId); + + yield* Fiber.interrupt(runtimeEventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("fails a Grok turn that stalls after ACP content begins", () => + Effect.gen(function* () { + const threadId = ThreadId.make("grok-watchdog-content-stall"); + const wrapperPath = yield* Effect.promise(() => + makeMockGrokWrapper({ + T3_ACP_EMIT_CONTENT_THEN_HANG: "1", + }), + ); + const adapter = yield* makeTestAdapter(wrapperPath, { + turnInactivityTimeoutMs: 1_000, + }); + const contentDelta = yield* Deferred.make(); + const turnCompleted = + yield* Deferred.make>(); + const runtimeEvents: ProviderRuntimeEvent[] = []; + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.gen(function* () { + if (String(event.threadId) !== String(threadId)) { + return; + } + runtimeEvents.push(event); + if (event.type === "content.delta") { + yield* Deferred.succeed(contentDelta, undefined).pipe(Effect.ignore); + } + if (event.type === "turn.completed") { + yield* Deferred.succeed(turnCompleted, event).pipe(Effect.ignore); + } + }), + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("grok"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + const sendTurnFiber = yield* adapter + .sendTurn({ threadId, input: "start then stall", attachments: [] }) + .pipe(Effect.forkChild); + yield* Deferred.await(contentDelta).pipe(Effect.timeout("2 seconds"), TestClock.withLive); + + yield* TestClock.adjust("999 millis"); + yield* Effect.yieldNow; + assert.lengthOf( + runtimeEvents.filter( + (event) => event.type === "turn.completed" && String(event.threadId) === String(threadId), + ), + 0, + ); + + yield* TestClock.adjust("1 millis"); + for (let yieldAttempt = 0; yieldAttempt < 4; yieldAttempt += 1) { + yield* Effect.yieldNow; + } + const completed = yield* Deferred.await(turnCompleted).pipe( + Effect.timeout("2 seconds"), + TestClock.withLive, + ); + yield* Fiber.join(sendTurnFiber); + + assert.equal(completed.payload.state, "failed"); + assert.equal( + runtimeEvents.filter( + (event) => event.type === "turn.completed" && String(event.threadId) === String(threadId), + ).length, + 1, + ); + + yield* Fiber.interrupt(runtimeEventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("refreshes Grok liveness when a turn is steered", () => + Effect.gen(function* () { + const threadId = ThreadId.make("grok-watchdog-steer"); + const wrapperPath = yield* Effect.promise(() => + makeMockGrokWrapper({ + T3_ACP_EMIT_CONTENT_THEN_HANG: "1", + }), + ); + const adapter = yield* makeTestAdapter(wrapperPath, { + turnInactivityTimeoutMs: 1_000, + }); + const contentDelta = yield* Deferred.make(); + const turnCompleted = + yield* Deferred.make>(); + const runtimeEvents: ProviderRuntimeEvent[] = []; + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.gen(function* () { + if (String(event.threadId) !== String(threadId)) { + return; + } + runtimeEvents.push(event); + if (event.type === "content.delta") { + yield* Deferred.succeed(contentDelta, undefined).pipe(Effect.ignore); + } + if (event.type === "turn.completed") { + yield* Deferred.succeed(turnCompleted, event).pipe(Effect.ignore); + } + }), + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("grok"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + const firstSendTurnFiber = yield* adapter + .sendTurn({ threadId, input: "start then steer", attachments: [] }) + .pipe(Effect.forkChild); + yield* Deferred.await(contentDelta).pipe(Effect.timeout("2 seconds"), TestClock.withLive); + + yield* TestClock.adjust("999 millis"); + const steerSendTurnFiber = yield* adapter + .sendTurn({ threadId, input: "continue working", attachments: [] }) + .pipe(Effect.forkChild); + for (let yieldAttempt = 0; yieldAttempt < 12; yieldAttempt += 1) { + yield* Effect.yieldNow; + } + + yield* TestClock.adjust("1 millis"); + for (let yieldAttempt = 0; yieldAttempt < 4; yieldAttempt += 1) { + yield* Effect.yieldNow; + } + assert.lengthOf( + runtimeEvents.filter( + (event) => event.type === "turn.completed" && String(event.threadId) === String(threadId), + ), + 0, + ); + + yield* Fiber.interrupt(steerSendTurnFiber); + yield* adapter.interruptTurn(threadId); + const completed = yield* Deferred.await(turnCompleted).pipe( + Effect.timeout("2 seconds"), + TestClock.withLive, + ); + yield* Fiber.join(firstSendTurnFiber); + assert.equal(completed.payload.state, "cancelled"); + + yield* Fiber.interrupt(runtimeEventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("refreshes Grok liveness when ACP updates its plan", () => + Effect.gen(function* () { + const threadId = ThreadId.make("grok-watchdog-plan-stall"); + const wrapperPath = yield* Effect.promise(() => + makeMockGrokWrapper({ + T3_ACP_EMIT_PLAN_THEN_HANG: "1", + }), + ); + const adapter = yield* makeTestAdapter(wrapperPath, { + turnInactivityTimeoutMs: 1_000, + }); + const planUpdated = yield* Deferred.make(); + const turnCompleted = + yield* Deferred.make>(); + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.gen(function* () { + if (String(event.threadId) !== String(threadId)) { + return; + } + if (event.type === "turn.plan.updated") { + yield* Deferred.succeed(planUpdated, undefined).pipe(Effect.ignore); + } + if (event.type === "turn.completed") { + yield* Deferred.succeed(turnCompleted, event).pipe(Effect.ignore); + } + }), + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("grok"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + const sendTurnFiber = yield* adapter + .sendTurn({ threadId, input: "update plan then stall", attachments: [] }) + .pipe(Effect.forkChild); + yield* Deferred.await(planUpdated).pipe(Effect.timeout("2 seconds"), TestClock.withLive); + + yield* TestClock.adjust("1 second"); + for (let yieldAttempt = 0; yieldAttempt < 4; yieldAttempt += 1) { + yield* Effect.yieldNow; + } + const completed = yield* Deferred.await(turnCompleted).pipe( + Effect.timeout("2 seconds"), + TestClock.withLive, + ); + yield* Fiber.join(sendTurnFiber); + + assert.equal(completed.payload.state, "failed"); + yield* Fiber.interrupt(runtimeEventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("settles a stalled Grok turn after the active-tool deadline", () => + Effect.gen(function* () { + const threadId = ThreadId.make("grok-watchdog-active-tool"); + const wrapperPath = yield* Effect.promise(() => + makeMockGrokWrapper({ + T3_ACP_EMIT_ACTIVE_TOOL_THEN_HANG: "1", + }), + ); + const adapter = yield* makeTestAdapter(wrapperPath, { + turnInactivityTimeoutMs: 1_000, + activeToolInactivityTimeoutMs: 5_000, + }); + const activeTool = yield* Deferred.make(); + const turnCompleted = + yield* Deferred.make>(); + const runtimeEvents: ProviderRuntimeEvent[] = []; + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.gen(function* () { + if (String(event.threadId) !== String(threadId)) { + return; + } + runtimeEvents.push(event); + if (event.type === "item.updated") { + yield* Deferred.succeed(activeTool, undefined).pipe(Effect.ignore); + } + if (event.type === "turn.completed") { + yield* Deferred.succeed(turnCompleted, event).pipe(Effect.ignore); + } + }), + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("grok"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + const sendTurnFiber = yield* adapter + .sendTurn({ threadId, input: "run a long tool", attachments: [] }) + .pipe(Effect.forkChild); + yield* Deferred.await(activeTool).pipe(Effect.timeout("2 seconds"), TestClock.withLive); + for (let yieldAttempt = 0; yieldAttempt < 4; yieldAttempt += 1) { + yield* Effect.yieldNow; + } + + yield* TestClock.adjust("4999 millis"); + yield* Effect.yieldNow; + assert.lengthOf( + runtimeEvents.filter( + (event) => event.type === "turn.completed" && String(event.threadId) === String(threadId), + ), + 0, + ); + assert.equal( + (yield* adapter.listSessions()).find((candidate) => candidate.threadId === threadId) + ?.status, + "running", + ); + + yield* TestClock.adjust("1 millis"); + for (let yieldAttempt = 0; yieldAttempt < 4; yieldAttempt += 1) { + yield* Effect.yieldNow; + } + const completed = yield* Deferred.await(turnCompleted).pipe( + Effect.timeout("2 seconds"), + TestClock.withLive, + ); + yield* Fiber.join(sendTurnFiber); + assert.equal(completed.payload.state, "failed"); + + yield* Fiber.interrupt(runtimeEventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + it.effect("retains turn transcript when sendTurn is interrupted after prompt success", () => Effect.gen(function* () { const threadId = ThreadId.make("grok-send-turn-interrupt-after-prompt"); @@ -943,6 +1389,64 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { }), ); + it.effect("surfaces Grok usage limits without clearing the selected model", () => + Effect.gen(function* () { + const threadId = ThreadId.make("grok-usage-limit-error"); + const wrapperPath = yield* Effect.promise(() => + makeMockGrokWrapper({ + T3_ACP_EMIT_XAI_RATE_LIMIT_THEN_HANG: "1", + }), + ); + const adapter = yield* makeTestAdapter(wrapperPath); + const runtimeEvents: ProviderRuntimeEvent[] = []; + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => { + runtimeEvents.push(event); + }), + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("grok"), + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: { instanceId: ProviderInstanceId.make("grok"), model: "grok-build" }, + }); + + const error = yield* Effect.flip( + adapter.sendTurn({ + threadId, + input: "hit the usage limit", + attachments: [], + }), + ); + const readySessions = yield* adapter.listSessions(); + const readySession = readySessions.find((session) => session.threadId === threadId); + const terminalEvents = runtimeEvents.filter( + (event) => event.type === "turn.completed" && event.threadId === threadId, + ); + + assert.equal(error._tag, "ProviderAdapterRequestError"); + assert.include(error.message, "Grok usage limit reached. Try again later."); + assert.equal(readySession?.status, "ready"); + assert.equal(readySession?.model, "grok-build"); + assert.isUndefined(readySession?.activeTurnId); + assert.lengthOf(terminalEvents, 1); + const [terminalEvent] = terminalEvents; + assert.equal(terminalEvent?.type, "turn.completed"); + if (terminalEvent?.type === "turn.completed") { + assert.equal(terminalEvent.payload.state, "failed"); + assert.include( + terminalEvent.payload.errorMessage ?? "", + "Grok usage limit reached. Try again later.", + ); + } + + yield* Fiber.interrupt(runtimeEventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + it.effect("ignores replayed session/load updates when resuming a Grok session", () => Effect.gen(function* () { const threadId = ThreadId.make("grok-load-replay-filter"); @@ -1097,6 +1601,247 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { }), ); + it.effect("captures xAI exit_plan_mode as a proposed plan and unblocks the turn", () => + Effect.gen(function* () { + const threadId = ThreadId.make("grok-xai-exit-plan-mode"); + const wrapperPath = yield* Effect.promise(() => + makeMockGrokWrapper({ T3_ACP_EMIT_XAI_EXIT_PLAN_MODE: "1" }), + ); + const adapter = yield* makeTestAdapter(wrapperPath); + const proposed = + yield* Deferred.make>(); + const turnCompleted = yield* Deferred.make(); + + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => { + if (String(event.threadId) !== String(threadId)) { + return Effect.void; + } + if (event.type === "turn.proposed.completed") { + return Deferred.succeed(proposed, event).pipe(Effect.ignore); + } + if (event.type === "turn.completed") { + return Deferred.succeed(turnCompleted, undefined).pipe(Effect.ignore); + } + return Effect.void; + }).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("grok"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + + yield* adapter.sendTurn({ threadId, input: "present the plan", attachments: [] }); + + const proposedEvent = yield* Deferred.await(proposed); + assert.equal(proposedEvent.type, "turn.proposed.completed"); + assert.equal(proposedEvent.payload.planMarkdown, "# Exit plan\n\n- Step one\n- Step two"); + assert.equal(proposedEvent.raw?.method, "_x.ai/exit_plan_mode"); + yield* Deferred.await(turnCompleted); + + yield* Fiber.interrupt(eventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("surfaces plan.md writes as a proposed plan while plan mode is active", () => + Effect.gen(function* () { + const threadId = ThreadId.make("grok-xai-plan-md-write"); + const wrapperPath = yield* Effect.promise(() => + makeMockGrokWrapper({ T3_ACP_EMIT_XAI_PLAN_MD_WRITE: "1" }), + ); + const adapter = yield* makeTestAdapter(wrapperPath); + const proposed = + yield* Deferred.make>(); + + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => { + if (String(event.threadId) !== String(threadId)) { + return Effect.void; + } + if (event.type === "turn.proposed.completed") { + return Deferred.succeed(proposed, event).pipe(Effect.ignore); + } + return Effect.void; + }).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("grok"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + + yield* adapter.sendTurn({ threadId, input: "write the plan", attachments: [] }); + + const proposedEvent = yield* Deferred.await(proposed); + assert.equal( + proposedEvent.payload.planMarkdown, + "# Mock plan\n\n- Write the feature\n- Add a test\n- Ship it", + ); + assert.equal(proposedEvent.raw?.method, "session/update"); + + yield* Fiber.interrupt(eventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("keeps a Grok turn running when Always allow has no allow_always option", () => + Effect.gen(function* () { + const threadId = ThreadId.make("grok-always-allow-without-allow-always"); + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "grok-acp-")), + ); + const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); + const wrapperPath = yield* Effect.promise(() => + makeMockGrokWrapper({ + T3_ACP_REQUEST_LOG_PATH: requestLogPath, + T3_ACP_EMIT_TOOL_CALLS: "1", + T3_ACP_OMIT_ALLOW_ALWAYS: "1", + T3_ACP_PERMISSION_REQUEST_COUNT: "2", + }), + ); + const adapter = yield* makeTestAdapter(wrapperPath); + const openedCount = yield* Ref.make(0); + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + event.type === "request.opened" + ? Effect.gen(function* () { + yield* Ref.update(openedCount, (count) => count + 1); + yield* adapter.respondToRequest( + threadId, + ApprovalRequestId.make(String(event.requestId)), + "acceptForSession", + ); + }) + : Effect.void, + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("grok"), + cwd: process.cwd(), + runtimeMode: "approval-required", + }); + yield* adapter.sendTurn({ + threadId, + input: "approve this session", + attachments: [], + }); + + assert.equal(yield* Ref.get(openedCount), 1); + + const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); + const permissionResults = requests.filter( + (entry) => + !("method" in entry) && + typeof entry.result === "object" && + entry.result !== null && + "outcome" in entry.result && + typeof entry.result.outcome === "object" && + entry.result.outcome !== null && + "optionId" in entry.result.outcome, + ); + assert.equal(permissionResults.length, 2); + assert.isTrue( + permissionResults.every( + (entry) => + typeof entry.result === "object" && + entry.result !== null && + "outcome" in entry.result && + typeof entry.result.outcome === "object" && + entry.result.outcome !== null && + "optionId" in entry.result.outcome && + entry.result.outcome.optionId === "allow-once", + ), + ); + + yield* Fiber.interrupt(eventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("asks before a different command after Always allow this session", () => + Effect.gen(function* () { + const threadId = ThreadId.make("grok-session-approval-scope"); + const wrapperPath = yield* Effect.promise(() => + makeMockGrokWrapper({ + T3_ACP_EMIT_TOOL_CALLS: "1", + T3_ACP_OMIT_ALLOW_ALWAYS: "1", + T3_ACP_PERMISSION_REQUEST_COUNT: "2", + T3_ACP_PERMISSION_TITLE: "Terminal", + T3_ACP_SECOND_PERMISSION_COMMAND: "rm server/package.json", + }), + ); + const adapter = yield* makeTestAdapter(wrapperPath); + const openedCount = yield* Ref.make(0); + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + event.type === "request.opened" + ? Effect.gen(function* () { + const count = yield* Ref.updateAndGet(openedCount, (value) => value + 1); + yield* adapter.respondToRequest( + threadId, + ApprovalRequestId.make(String(event.requestId)), + count === 1 ? "acceptForSession" : "decline", + ); + }) + : Effect.void, + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("grok"), + cwd: process.cwd(), + runtimeMode: "approval-required", + }); + yield* adapter.sendTurn({ threadId, input: "check approval scope", attachments: [] }); + assert.equal(yield* Ref.get(openedCount), 2); + yield* Fiber.interrupt(eventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("captures a plan under the provider instance GROK_HOME", () => + Effect.gen(function* () { + const threadId = ThreadId.make("grok-instance-plan-home"); + const grokHome = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "grok-instance-home-")), + ); + const wrapperPath = yield* Effect.promise(() => + makeMockGrokWrapper({ + T3_ACP_EMIT_XAI_PLAN_MD_WRITE: "1", + T3_ACP_PLAN_ROOT: grokHome, + }), + ); + const adapter = yield* makeTestAdapter(wrapperPath, { + environment: { ...process.env, GROK_HOME: grokHome }, + }); + const plans = yield* Ref.make>([]); + const completed = yield* Deferred.make(); + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => { + if (event.type === "turn.proposed.completed") { + return Ref.update(plans, (current) => [...current, event.payload.planMarkdown]); + } + return event.type === "turn.completed" + ? Deferred.succeed(completed, undefined).pipe(Effect.asVoid) + : Effect.void; + }).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("grok"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ threadId, input: "write the plan", attachments: [] }); + yield* Deferred.await(completed); + assert.deepEqual(yield* Ref.get(plans), [ + "# Mock plan\n\n- Write the feature\n- Add a test\n- Ship it", + ]); + yield* Fiber.interrupt(eventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + it.effect("handles xAI ask_user_question extension requests", () => Effect.gen(function* () { const threadId = ThreadId.make("grok-xai-ask-user-question"); @@ -1159,6 +1904,82 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { }), ); + it.effect("settles a stalled Grok turn after its first activity is user input", () => + Effect.gen(function* () { + const threadId = ThreadId.make("grok-xai-ask-user-question"); + const wrapperPath = yield* Effect.promise(() => + makeMockGrokWrapper({ T3_ACP_EMIT_XAI_ASK_USER_QUESTION_THEN_HANG: "1" }), + ); + const adapter = yield* makeTestAdapter(wrapperPath, { + turnInactivityTimeoutMs: 1_000, + }); + const requested = + yield* Deferred.make>(); + const resolved = + yield* Deferred.make>(); + const completed = + yield* Deferred.make>(); + + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => { + if (String(event.threadId) !== String(threadId)) { + return Effect.void; + } + if (event.type === "user-input.requested") { + return Deferred.succeed(requested, event).pipe(Effect.ignore); + } + if (event.type === "user-input.resolved") { + return Deferred.succeed(resolved, event).pipe(Effect.ignore); + } + if (event.type === "turn.completed") { + return Deferred.succeed(completed, event).pipe(Effect.ignore); + } + return Effect.void; + }).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("grok"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + + const sendTurnFiber = yield* adapter + .sendTurn({ threadId, input: "ask before continuing", attachments: [] }) + .pipe(Effect.forkChild); + + const requestedEvent = yield* Deferred.await(requested); + assert.equal(requestedEvent.payload.questions.length, 1); + assert.equal(requestedEvent.payload.questions[0]?.id, "Which scope should Grok use?"); + assert.equal(requestedEvent.payload.questions[0]?.question, "Which scope should Grok use?"); + assert.equal(requestedEvent.raw?.method, "_x.ai/ask_user_question"); + + yield* adapter.respondToUserInput( + threadId, + ApprovalRequestId.make(String(requestedEvent.requestId)), + { + "Which scope should Grok use?": "Workspace", + }, + ); + + const resolvedEvent = yield* Deferred.await(resolved); + assert.deepEqual(resolvedEvent.payload.answers, { + "Which scope should Grok use?": "Workspace", + }); + assert.equal(String(resolvedEvent.turnId), String(requestedEvent.turnId)); + + yield* TestClock.adjust("1 second"); + const completedEvent = yield* Deferred.await(completed).pipe( + Effect.timeout("2 seconds"), + TestClock.withLive, + ); + assert.equal(completedEvent.payload.state, "failed"); + yield* Fiber.join(sendTurnFiber); + + yield* Fiber.interrupt(eventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + it.effect("continues streaming events when native notification logging fails", () => Effect.gen(function* () { const threadId = ThreadId.make("grok-native-log-failure"); diff --git a/apps/server/src/provider/Layers/GrokAdapter.ts b/apps/server/src/provider/Layers/GrokAdapter.ts index 858d862e6d5f..4bec298c628d 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.ts @@ -12,9 +12,14 @@ import { type ThreadId, TurnId, } from "@t3tools/contracts"; +import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; +import { stableStringify } from "@t3tools/shared/relaySigning"; +import * as Clock from "effect/Clock"; import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; @@ -22,6 +27,7 @@ import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as PubSub from "effect/PubSub"; +import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; @@ -56,15 +62,21 @@ import { makeAcpNativeLoggerFactory } from "../acp/AcpNativeLogging.ts"; import { applyGrokAcpModelSelection, currentGrokModelIdFromSessionSetup, + currentGrokReasoningEffortFromSessionSetup, makeGrokAcpRuntime, + normalizeGrokReasoningEffort, resolveGrokAcpBaseModelId, } from "../acp/GrokAcpSupport.ts"; import { + extractGrokPlanMarkdownFromToolCallData, extractXAiAskUserQuestions, + extractXAiExitPlanMarkdown, makeXAiAskUserQuestionCancelledResponse, makeXAiAskUserQuestionResponse, + makeXAiExitPlanModeCapturedResponse, promptResponseHasMissingXAiStopReason, XAiAskUserQuestionRequest, + XAiExitPlanModeRequest, } from "../acp/XAiAcpExtension.ts"; import { type GrokAdapterShape } from "../Services/GrokAdapter.ts"; import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; @@ -73,6 +85,15 @@ const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.fromJsonStri const PROVIDER = ProviderDriverKind.make("grok"); const GROK_RESUME_VERSION = 1 as const; +const NANOS_PER_MILLI = 1_000_000n; +// ACP does not expose Grok's private `streaming_reasoning` phase. Once it has +// emitted standard ACP progress, ten silent minutes is long enough to avoid +// treating legitimate reasoning as a stalled stream. +const DEFAULT_GROK_TURN_INACTIVITY_TIMEOUT_MS = 10 * 60 * 1_000; +// A tool can legitimately run without emitting text for much longer than +// reasoning. It still needs a deadline so a lost tool update cannot leave the +// turn working forever. +const DEFAULT_GROK_ACTIVE_TOOL_INACTIVITY_TIMEOUT_MS = 30 * 60 * 1_000; function encodeJsonStringForDiagnostics(input: unknown): string | undefined { const result = encodeUnknownJsonStringExit(input); @@ -84,6 +105,10 @@ export interface GrokAdapterLiveOptions { readonly nativeEventLogPath?: string; readonly nativeEventLogger?: EventNdjsonLogger; readonly instanceId?: ProviderInstanceId; + /** Override the conservative ACP turn liveness timeout in focused tests. */ + readonly turnInactivityTimeoutMs?: number; + /** Override the longer active-tool liveness timeout in focused tests. */ + readonly activeToolInactivityTimeoutMs?: number; } interface PendingApproval { @@ -98,6 +123,10 @@ interface PendingUserInput { readonly resolution: Deferred.Deferred; } +interface GrokTurnLivenessSignal { + readonly turnId: TurnId; +} + interface GrokSessionContext { readonly threadId: ThreadId; readonly acpSessionId: string; @@ -109,6 +138,14 @@ interface GrokSessionContext { readonly pendingUserInputs: Map; turns: Array<{ id: TurnId; items: Array }>; lastPlanFingerprint: string | undefined; + /** + * Latest plan.md body + turn it was emitted for. Dedupe is turn-scoped so a + * later turn re-proposing the same text still gets a new proposed-plan card. + */ + lastKnownProposedPlanMarkdown: string | undefined; + lastKnownProposedPlanTurnId: TurnId | undefined; + /** True after enter_plan_mode until the turn ends or exit_plan_mode resolves. */ + planModeActive: boolean; activeTurnId: TurnId | undefined; /** Turns already interrupted; late prompt RPCs must not resurrect them. */ interruptedTurnIds: Set; @@ -116,7 +153,15 @@ interface GrokSessionContext { * >0 means a turn is actively running, so a new sendTurn is a steer that * continues it, and only the last remaining prompt settles the turn. */ promptsInFlight: number; + readonly livenessSignals: Queue.Queue; + livenessTurnId: TurnId | undefined; + lastTurnActivityAtNanos: bigint | undefined; + readonly activeToolCallIds: Set; + livenessUpdatesInFlight: number; + /** Prompt RPCs that returned before their turn settlement acquired the lock. */ + promptResponsesReady: number; currentModelId: string | undefined; + currentReasoningEffort: string | undefined; stopped: boolean; } @@ -164,6 +209,54 @@ const resolveNotificationTurnId = (ctx: GrokSessionContext): TurnId | undefined const resolveCallbackTurnId = (ctx: GrokSessionContext): TurnId | undefined => ctx.activeTurnId; +function clearProposedPlanFallback(ctx: GrokSessionContext): void { + ctx.lastKnownProposedPlanMarkdown = undefined; + ctx.lastKnownProposedPlanTurnId = undefined; + ctx.planModeActive = false; +} + +/** Detect Grok's enter_plan_mode tool call from ACP tool state. */ +export function isGrokEnterPlanModeToolCall(toolCall: { + readonly title?: string; + readonly data: Record; +}): boolean { + const title = toolCall.title?.trim().toLowerCase() ?? ""; + if ( + title === "enter_plan_mode" || + title === "plan: enter" || + title === "plan mode entered" || + title.includes("enter_plan_mode") + ) { + return true; + } + const rawInput = toolCall.data.rawInput; + if (isRecord(rawInput) && rawInput.variant === "EnterPlanMode") { + return true; + } + return false; +} + +/** Failed enter_plan_mode must not leave planModeActive stuck on. */ +export function nextGrokPlanModeActive( + currentlyActive: boolean, + toolCall: { + readonly title?: string; + readonly status?: "pending" | "inProgress" | "completed" | "failed"; + readonly data: Record; + }, +): boolean { + if (!isGrokEnterPlanModeToolCall(toolCall)) { + return currentlyActive; + } + if (toolCall.status === "failed") { + return false; + } + if (toolCall.status === "completed" || toolCall.status === "inProgress") { + return true; + } + return currentlyActive; +} + const resolveSessionCallbackTurnId = ( sessions: ReadonlyMap, threadId: ThreadId, @@ -179,26 +272,38 @@ function parseGrokResume(raw: unknown): { sessionId: string } | undefined { return { sessionId: raw.sessionId.trim() }; } -function selectPermissionOptionId( +export function selectGrokPermissionOptionId( request: EffectAcpSchema.RequestPermissionRequest, decision: Exclude, ): string | undefined { - const kind = + const preferredKind = decision === "acceptForSession" ? "allow_always" : decision === "accept" ? "allow_once" : "reject_once"; - const option = request.options.find((entry) => entry.kind === kind); - return option?.optionId.trim() || undefined; + const preferred = request.options.find((entry) => entry.kind === preferredKind); + const preferredId = preferred?.optionId.trim(); + if (preferredId) { + return preferredId; + } + // Grok 4.6 often omits allow_always. T3 still offers "Always allow this session". + if (decision === "acceptForSession") { + const once = request.options.find((entry) => entry.kind === "allow_once"); + const onceId = once?.optionId.trim(); + if (onceId) { + return onceId; + } + } + return undefined; } function selectAutoApprovedPermissionOption( request: EffectAcpSchema.RequestPermissionRequest, ): string | undefined { return ( - selectPermissionOptionId(request, "acceptForSession") ?? - selectPermissionOptionId(request, "accept") + selectGrokPermissionOptionId(request, "acceptForSession") ?? + selectGrokPermissionOptionId(request, "accept") ); } @@ -240,10 +345,31 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte const managedNativeEventLogger = options?.nativeEventLogger === undefined ? nativeEventLogger : undefined; const makeAcpNativeLoggers = yield* makeAcpNativeLoggerFactory(); + const hostPlatform = yield* HostProcessPlatform; + const hostEnvironment = yield* HostProcessEnvironment; + const grokPlanPathHost = { + platform: hostPlatform, + environment: options?.environment ?? hostEnvironment, + }; const sessions = new Map(); const threadLocksRef = yield* SynchronizedRef.make(new Map()); const runtimeEventPubSub = yield* PubSub.unbounded(); + const requestedTurnInactivityTimeoutMs = options?.turnInactivityTimeoutMs; + const turnInactivityTimeoutMs = + typeof requestedTurnInactivityTimeoutMs === "number" && + Number.isFinite(requestedTurnInactivityTimeoutMs) + ? Math.max(1, Math.floor(requestedTurnInactivityTimeoutMs)) + : DEFAULT_GROK_TURN_INACTIVITY_TIMEOUT_MS; + const turnInactivityTimeoutNanos = BigInt(turnInactivityTimeoutMs) * NANOS_PER_MILLI; + const requestedActiveToolInactivityTimeoutMs = options?.activeToolInactivityTimeoutMs; + const activeToolInactivityTimeoutMs = + typeof requestedActiveToolInactivityTimeoutMs === "number" && + Number.isFinite(requestedActiveToolInactivityTimeoutMs) + ? Math.max(1, Math.floor(requestedActiveToolInactivityTimeoutMs)) + : DEFAULT_GROK_ACTIVE_TOOL_INACTIVITY_TIMEOUT_MS; + const activeToolInactivityTimeoutNanos = + BigInt(activeToolInactivityTimeoutMs) * NANOS_PER_MILLI; const nowIso = Effect.map(DateTime.now, DateTime.formatIso); const randomUUIDv4 = crypto.randomUUIDv4.pipe( @@ -294,6 +420,146 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte const withThreadLock = (threadId: string, effect: Effect.Effect) => Effect.flatMap(getThreadSemaphore(threadId), (semaphore) => semaphore.withPermit(effect)); + const signalTurnLiveness = (ctx: GrokSessionContext, turnId: TurnId) => + Queue.offer(ctx.livenessSignals, { turnId }).pipe(Effect.asVoid); + + const beginTurnLiveness = (ctx: GrokSessionContext, turnId: TurnId) => + Effect.sync(() => { + ctx.livenessTurnId = turnId; + // Do not start a deadline until ACP has made observable progress. + // Grok's private reasoning phase is not present in the ACP stream. + ctx.lastTurnActivityAtNanos = undefined; + ctx.activeToolCallIds.clear(); + }); + + const clearTurnLiveness = (ctx: GrokSessionContext) => { + const turnId = ctx.livenessTurnId; + ctx.livenessTurnId = undefined; + ctx.lastTurnActivityAtNanos = undefined; + ctx.activeToolCallIds.clear(); + ctx.livenessUpdatesInFlight = 0; + ctx.promptResponsesReady = 0; + return turnId === undefined ? Effect.void : signalTurnLiveness(ctx, turnId); + }; + + const recordTurnActivity = Effect.fn("GrokAdapter.recordTurnActivity")(function* ( + ctx: GrokSessionContext, + turnId: TurnId, + event: Extract< + AcpSessionRuntime.AcpSessionRuntimeEvent, + { + _tag: + | "AssistantItemStarted" + | "AssistantItemCompleted" + | "PlanUpdated" + | "ToolCallUpdated" + | "ContentDelta"; + } + >, + ) { + if ( + ctx.livenessTurnId !== turnId || + (event._tag === "ContentDelta" && event.text.length === 0) + ) { + return; + } + ctx.livenessUpdatesInFlight += 1; + try { + const activityAtNanos = yield* Clock.monotonicTimeNanos; + if (ctx.livenessTurnId !== turnId || ctx.interruptedTurnIds.has(turnId)) { + return; + } + if (event._tag === "ToolCallUpdated") { + if (event.toolCall.status === "completed" || event.toolCall.status === "failed") { + ctx.activeToolCallIds.delete(event.toolCall.toolCallId); + } else { + // A tool update without a terminal status receives a longer + // deadline so a long-running tool is not mistaken for a stall. + ctx.activeToolCallIds.add(event.toolCall.toolCallId); + } + } + ctx.lastTurnActivityAtNanos = activityAtNanos; + } finally { + // Decrement before signaling. The watchdog treats in-flight updates as a + // pause; if it consumed a signal while the counter was still > 0 it would + // wait on the next take with no follow-up wake after this decrement. + ctx.livenessUpdatesInFlight = Math.max(0, ctx.livenessUpdatesInFlight - 1); + yield* signalTurnLiveness(ctx, turnId); + } + }); + + const hasLivenessPause = (ctx: GrokSessionContext) => + ctx.pendingApprovals.size > 0 || + ctx.pendingUserInputs.size > 0 || + ctx.livenessUpdatesInFlight > 0; + + const livenessTimeoutFor = (ctx: GrokSessionContext) => + ctx.activeToolCallIds.size > 0 + ? { + milliseconds: activeToolInactivityTimeoutMs, + nanos: activeToolInactivityTimeoutNanos, + } + : { milliseconds: turnInactivityTimeoutMs, nanos: turnInactivityTimeoutNanos }; + + const signalSessionTurnLiveness = (threadId: ThreadId, turnId: TurnId | undefined) => { + const ctx = sessions.get(threadId); + return ctx && turnId !== undefined ? signalTurnLiveness(ctx, turnId) : Effect.void; + }; + + const resumeSessionTurnLiveness = Effect.fn("GrokAdapter.resumeSessionTurnLiveness")(function* ( + threadId: ThreadId, + turnId: TurnId | undefined, + ) { + const ctx = sessions.get(threadId); + if (!ctx || turnId === undefined || ctx.livenessTurnId !== turnId) { + return; + } + // An approval or user-input wait can last longer than the watchdog. + // Its resolution gives the provider a fresh window to resume output. + ctx.lastTurnActivityAtNanos = yield* Clock.monotonicTimeNanos; + yield* signalTurnLiveness(ctx, turnId); + }); + + const refreshSessionTurnLiveness = Effect.fn("GrokAdapter.refreshSessionTurnLiveness")( + function* (threadId: ThreadId, turnId: TurnId | undefined) { + const ctx = sessions.get(threadId); + if ( + !ctx || + turnId === undefined || + ctx.livenessTurnId !== turnId || + ctx.lastTurnActivityAtNanos === undefined + ) { + return; + } + ctx.lastTurnActivityAtNanos = yield* Clock.monotonicTimeNanos; + yield* signalTurnLiveness(ctx, turnId); + }, + ); + + const markPromptResponseReady = Effect.fn("GrokAdapter.markPromptResponseReady")(function* ( + threadId: ThreadId, + acpSessionId: string, + turnId: TurnId, + ) { + const ctx = sessions.get(threadId); + if ( + ctx && + ctx.acpSessionId === acpSessionId && + !ctx.stopped && + !ctx.interruptedTurnIds.has(turnId) && + ctx.livenessTurnId === turnId && + ctx.activeTurnId === turnId && + ctx.session.activeTurnId === turnId + ) { + ctx.promptResponsesReady += 1; + yield* signalTurnLiveness(ctx, turnId); + } + }); + + const consumePromptResponseReady = (ctx: GrokSessionContext) => { + ctx.promptResponsesReady = Math.max(0, ctx.promptResponsesReady - 1); + }; + const settlePromptInFlight = ( threadId: ThreadId, turnId: TurnId, @@ -373,6 +639,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte updatedAt, }; } + yield* clearTurnLiveness(liveCtx); return; } settleTurnId = fallbackTurnId; @@ -389,6 +656,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte } liveCtx.promptsInFlight = remainingPrompts; } + yield* clearTurnLiveness(liveCtx); const updatedAt = yield* nowIso; const canEmitTurnCompletion = liveCtx.session.status === "running" || liveCtx.session.status === "connecting"; @@ -397,6 +665,9 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte options?.completedStopReason !== undefined && canEmitTurnCompletion; const { activeTurnId: _activeTurnId, ...readySession } = liveCtx.session; liveCtx.activeTurnId = undefined; + // Drop turn-scoped plan fallback so a later empty exit_plan cannot + // resurrect this turn's markdown as a fresh proposal. + clearProposedPlanFallback(liveCtx); liveCtx.session = { ...readySession, status: "ready", @@ -432,6 +703,104 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte } }); + const isLiveTurn = (ctx: GrokSessionContext, turnId: TurnId) => + ctx.promptsInFlight > 0 && + ctx.promptsInFlight > ctx.promptResponsesReady && + ctx.activeTurnId === turnId && + ctx.session.activeTurnId === turnId && + (ctx.session.status === "running" || ctx.session.status === "connecting"); + + const settleStalledTurn = Effect.fn("GrokAdapter.settleStalledTurn")(function* ( + ctx: GrokSessionContext, + turnId: TurnId, + ) { + return yield* withThreadLock( + ctx.threadId, + Effect.gen(function* () { + const liveCtx = sessions.get(ctx.threadId); + if ( + liveCtx !== ctx || + ctx.stopped || + !isLiveTurn(ctx, turnId) || + ctx.interruptedTurnIds.has(turnId) || + hasLivenessPause(ctx) + ) { + return; + } + const lastActivityAtNanos = ctx.lastTurnActivityAtNanos; + if (lastActivityAtNanos === undefined) { + return; + } + const nowNanos = yield* Clock.monotonicTimeNanos; + if ( + ctx.interruptedTurnIds.has(turnId) || + !isLiveTurn(ctx, turnId) || + hasLivenessPause(ctx) || + nowNanos - lastActivityAtNanos < livenessTimeoutFor(ctx).nanos + ) { + return; + } + + // Mark before cancel/drain so notifications already in flight finish + // before the terminal event, while late notifications are dropped. + ctx.interruptedTurnIds.add(turnId); + yield* Effect.ignore( + ctx.acp.cancel.pipe( + Effect.mapError((error) => + mapAcpToAdapterError(PROVIDER, ctx.threadId, "session/cancel", error), + ), + ), + ); + yield* Effect.ignore(ctx.acp.drainEvents); + yield* settlePromptInFlight(ctx.threadId, turnId, ctx.acpSessionId, { + errorMessage: `Grok ACP turn stalled without content or tool progress for ${livenessTimeoutFor(ctx).milliseconds}ms.`, + settleAllPrompts: true, + }); + }), + ); + }); + + const runTurnLivenessWatchdog = Effect.fn("GrokAdapter.runTurnLivenessWatchdog")( + function* (ctx: GrokSessionContext) { + while (true) { + if (ctx.stopped) { + return; + } + const turnId = ctx.livenessTurnId; + if ( + turnId === undefined || + ctx.interruptedTurnIds.has(turnId) || + !isLiveTurn(ctx, turnId) || + hasLivenessPause(ctx) + ) { + yield* Queue.take(ctx.livenessSignals); + continue; + } + + const lastActivityAtNanos = ctx.lastTurnActivityAtNanos; + if (lastActivityAtNanos === undefined) { + yield* Queue.take(ctx.livenessSignals); + continue; + } + const nowNanos = yield* Clock.monotonicTimeNanos; + const remainingNanos = livenessTimeoutFor(ctx).nanos - (nowNanos - lastActivityAtNanos); + if (remainingNanos <= 0n) { + yield* settleStalledTurn(ctx, turnId); + continue; + } + + const wakeReason = yield* Effect.raceFirst( + Effect.sleep(Duration.nanos(remainingNanos)).pipe(Effect.as("timeout" as const)), + Queue.take(ctx.livenessSignals).pipe(Effect.as("activity" as const)), + ); + if (wakeReason === "timeout") { + yield* settleStalledTurn(ctx, turnId); + } + } + }, + Effect.catch(() => Effect.void), + ); + const logNative = (threadId: ThreadId, method: string, payload: unknown) => Effect.gen(function* () { if (!nativeEventLogger) return; @@ -495,6 +864,45 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte ); }); + /** Surface Grok plan.md as T3's proposed-plan card (while writing + on exit). */ + const emitProposedPlanCompleted = ( + ctx: GrokSessionContext, + turnId: TurnId | undefined, + stamp: { readonly eventId: EventId; readonly createdAt: string }, + planMarkdown: string, + raw: { readonly method: string; readonly payload: unknown }, + ) => + Effect.gen(function* () { + const trimmed = planMarkdown.trim(); + if (trimmed.length === 0) { + ctx.lastKnownProposedPlanMarkdown = ""; + ctx.lastKnownProposedPlanTurnId = turnId; + return; + } + // Turn-scoped dedupe: identical text on a later turn must still emit. + if ( + ctx.lastKnownProposedPlanMarkdown === trimmed && + ctx.lastKnownProposedPlanTurnId === turnId + ) { + return; + } + ctx.lastKnownProposedPlanMarkdown = trimmed; + ctx.lastKnownProposedPlanTurnId = turnId; + yield* offerRuntimeEvent({ + type: "turn.proposed.completed", + ...stamp, + provider: PROVIDER, + threadId: ctx.threadId, + turnId, + payload: { planMarkdown: trimmed }, + raw: { + source: "acp.grok.extension", + method: raw.method, + payload: raw.payload, + }, + }); + }); + const requireSession = ( threadId: ThreadId, ): Effect.Effect => { @@ -556,6 +964,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte const pendingApprovals = new Map(); const pendingUserInputs = new Map(); + const sessionApprovedOperations = new Set(); const sessionScope = yield* Scope.make("sequential"); let sessionScopeTransferred = false; yield* Effect.addFinalizer(() => @@ -575,6 +984,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte ...(options?.environment ? { environment: options.environment } : {}), childProcessSpawner, cwd, + runtimeMode: input.runtimeMode, ...(resumeSessionId ? { resumeSessionId } : {}), clientInfo: { name: "t3-code", version: "0.0.0" }, ...(mcpSession @@ -621,6 +1031,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte const resolution = yield* Deferred.make(); const turnId = resolveSessionCallbackTurnId(sessions, input.threadId); pendingUserInputs.set(requestId, { resolution }); + yield* signalSessionTurnLiveness(input.threadId, turnId); yield* offerRuntimeEvent({ type: "user-input.requested", ...(yield* makeEventStamp()), @@ -637,6 +1048,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte }); const resolved = yield* Deferred.await(resolution); pendingUserInputs.delete(requestId); + yield* resumeSessionTurnLiveness(input.threadId, turnId); const resolvedAnswers = resolved._tag === "answered" ? resolved.answers : {}; yield* offerRuntimeEvent({ type: "user-input.resolved", @@ -663,12 +1075,77 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte ), { discard: true }, ); + // Grok intercepts exit_plan_mode and reverse-requests client approval. + // Capture plan into T3 proposed-plan UI and abandon the native gate so + // the turn does not hang (Claude ExitPlanMode pattern). + yield* Effect.forEach( + ["x.ai/exit_plan_mode", "_x.ai/exit_plan_mode"] as const, + (method) => + acp.handleExtRequest(method, XAiExitPlanModeRequest, (params) => + mapAcpCallbackFailure( + Effect.gen(function* () { + yield* logNative(input.threadId, method, params); + const turnId = resolveSessionCallbackTurnId(sessions, input.threadId); + const ctx = sessions.get(input.threadId); + const planMarkdown = extractXAiExitPlanMarkdown( + params, + ctx?.lastKnownProposedPlanMarkdown, + ); + if (ctx) { + yield* emitProposedPlanCompleted( + ctx, + turnId, + yield* makeEventStamp(), + planMarkdown, + { method, payload: params }, + ); + ctx.planModeActive = false; + } else { + yield* offerRuntimeEvent({ + type: "turn.proposed.completed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + turnId, + payload: { planMarkdown }, + raw: { + source: "acp.grok.extension", + method, + payload: params, + }, + }); + } + return makeXAiExitPlanModeCapturedResponse(); + }), + ), + ), + { discard: true }, + ); yield* acp.handleRequestPermission((params) => mapAcpCallbackFailure( Effect.gen(function* () { yield* logNative(input.threadId, "session/request_permission", params); - if (input.runtimeMode === "full-access") { - const autoApprovedOptionId = selectAutoApprovedPermissionOption(params); + const permissionRequest = parsePermissionRequest(params); + const command = permissionRequest.toolCall?.command; + const { kind, title, rawInput, locations } = params.toolCall; + let operationInput = rawInput; + if (isRecord(rawInput) && rawInput.variant === "Bash") { + const { description: _description, ...shellInput } = rawInput; + operationInput = shellInput; + } + // Remember the operation, not the tool-call id or every future tool. + // Generic titles without input cannot identify an operation safely. + const approvalKey = + command || (isRecord(rawInput) && Object.keys(rawInput).length > 0) + ? stableStringify({ kind, title, command, input: operationInput, locations }) + : undefined; + const alreadyApproved = + approvalKey !== undefined && sessionApprovedOperations.has(approvalKey); + if (input.runtimeMode === "full-access" || alreadyApproved) { + const autoApprovedOptionId = + input.runtimeMode === "full-access" + ? selectAutoApprovedPermissionOption(params) + : selectGrokPermissionOptionId(params, "accept"); if (autoApprovedOptionId !== undefined) { return { outcome: { @@ -678,12 +1155,12 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte }; } } - const permissionRequest = parsePermissionRequest(params); const requestId = ApprovalRequestId.make(yield* randomUUIDv4); const runtimeRequestId = RuntimeRequestId.make(requestId); const decision = yield* Deferred.make(); const turnId = resolveSessionCallbackTurnId(sessions, input.threadId); pendingApprovals.set(requestId, { decision }); + yield* signalSessionTurnLiveness(input.threadId, turnId); yield* offerRuntimeEvent( makeAcpRequestOpenedEvent({ stamp: yield* makeEventStamp(), @@ -704,6 +1181,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte ); const resolved = yield* Deferred.await(decision); pendingApprovals.delete(requestId); + yield* resumeSessionTurnLiveness(input.threadId, turnId); yield* offerRuntimeEvent( makeAcpRequestResolvedEvent({ stamp: yield* makeEventStamp(), @@ -716,7 +1194,16 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte }), ); const selectedOptionId = - resolved === "cancel" ? undefined : selectPermissionOptionId(params, resolved); + resolved === "cancel" + ? undefined + : selectGrokPermissionOptionId(params, resolved); + if ( + resolved === "acceptForSession" && + selectedOptionId && + approvalKey !== undefined + ) { + sessionApprovedOperations.add(approvalKey); + } return { outcome: selectedOptionId ? { @@ -738,10 +1225,22 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte const requestedStartModelId = grokModelSelection?.model ? resolveGrokAcpBaseModelId(grokModelSelection.model) : undefined; + const currentStartModelId = currentGrokModelIdFromSessionSetup( + started.sessionSetupResult, + ); + const currentStartReasoningEffort = currentGrokReasoningEffortFromSessionSetup( + started.sessionSetupResult, + ); + const requestedStartReasoningEffort = getModelSelectionStringOptionValue( + grokModelSelection, + "reasoningEffort", + ); const boundModelId = yield* applyGrokAcpModelSelection({ runtime: acp, - currentModelId: currentGrokModelIdFromSessionSetup(started.sessionSetupResult), + currentModelId: currentStartModelId, + currentReasoningEffort: currentStartReasoningEffort, requestedModelId: requestedStartModelId, + requestedReasoningEffort: requestedStartReasoningEffort, mapError: (cause) => mapAcpToAdapterError(PROVIDER, input.threadId, "session/set_model", cause), }); @@ -774,10 +1273,23 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte pendingUserInputs, turns: [], lastPlanFingerprint: undefined, + lastKnownProposedPlanMarkdown: undefined, + lastKnownProposedPlanTurnId: undefined, + planModeActive: false, activeTurnId: undefined, interruptedTurnIds: new Set(), promptsInFlight: 0, + livenessSignals: yield* Queue.sliding(1), + livenessTurnId: undefined, + lastTurnActivityAtNanos: undefined, + activeToolCallIds: new Set(), + livenessUpdatesInFlight: 0, + promptResponsesReady: 0, currentModelId: boundModelId, + currentReasoningEffort: + requestedStartReasoningEffort !== undefined + ? normalizeGrokReasoningEffort(requestedStartReasoningEffort) + : currentStartReasoningEffort, stopped: false, }; @@ -807,6 +1319,15 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte ) { return; } + if ( + event._tag === "AssistantItemStarted" || + event._tag === "AssistantItemCompleted" || + event._tag === "PlanUpdated" || + event._tag === "ToolCallUpdated" || + event._tag === "ContentDelta" + ) { + yield* recordTurnActivity(ctx, notificationTurnId, event); + } const stamp = yield* makeEventStamp(); switch (event._tag) { @@ -844,7 +1365,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte "session/update", ); return; - case "ToolCallUpdated": + case "ToolCallUpdated": { yield* offerRuntimeEvent( makeAcpToolCallEvent({ stamp, @@ -855,7 +1376,30 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte rawPayload: event.rawPayload, }), ); + ctx.planModeActive = nextGrokPlanModeActive(ctx.planModeActive, event.toolCall); + // Only promote session plan.md writes while plan mode is + // active — avoids treating unrelated plan files as proposals. + // Fresh stamp: must not share eventId with the tool lifecycle event. + if (ctx.planModeActive) { + const planMarkdown = extractGrokPlanMarkdownFromToolCallData( + event.toolCall.data, + grokPlanPathHost, + ); + if (planMarkdown !== undefined) { + yield* emitProposedPlanCompleted( + ctx, + notificationTurnId, + yield* makeEventStamp(), + planMarkdown, + { + method: "session/update", + payload: event.rawPayload, + }, + ); + } + } return; + } case "ContentDelta": yield* offerRuntimeEvent( makeAcpContentDeltaEvent({ @@ -887,6 +1431,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte ctx.notificationFiber = nf; sessions.set(input.threadId, ctx); + yield* runTurnLivenessWatchdog(ctx).pipe(Effect.forkIn(ctx.scope), Effect.asVoid); sessionScopeTransferred = true; yield* offerRuntimeEvent({ @@ -933,6 +1478,11 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte // Bind the turn id before cooperative yields so interruptTurn can // settle this prompt even if stop arrives during preparation. ctx.activeTurnId = turnId; + // New turn: do not fall back to a previous turn's plan.md body when + // exit_plan_mode omits planContent. + if (steeringTurnId === undefined) { + clearProposedPlanFallback(ctx); + } ctx.session = { ...ctx.session, status: steeringTurnId === undefined ? "connecting" : "running", @@ -948,17 +1498,16 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte const requestedTurnModelId = turnModelSelection?.model ? resolveGrokAcpBaseModelId(turnModelSelection.model) : undefined; - const currentModelId = yield* applyGrokAcpModelSelection({ - runtime: ctx.acp, - currentModelId: ctx.currentModelId, - requestedModelId: requestedTurnModelId, - mapError: (cause) => - mapAcpToAdapterError(PROVIDER, input.threadId, "session/set_model", cause), - }); + const requestedTurnReasoningEffort = getModelSelectionStringOptionValue( + turnModelSelection, + "reasoningEffort", + ); const text = input.input?.trim(); + // Grok ingests images only. Generic files reach the agent + // through the path line ProviderService puts in the prompt. const imagePromptParts = yield* Effect.forEach( - input.attachments ?? [], + (input.attachments ?? []).filter((attachment) => attachment.type === "image"), (attachment) => Effect.gen(function* () { const attachmentPath = resolveAttachmentPath({ @@ -1003,7 +1552,21 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte }); } + const currentModelId = yield* applyGrokAcpModelSelection({ + runtime: ctx.acp, + currentModelId: ctx.currentModelId, + currentReasoningEffort: ctx.currentReasoningEffort, + requestedModelId: requestedTurnModelId, + requestedReasoningEffort: requestedTurnReasoningEffort, + mapError: (cause) => + mapAcpToAdapterError(PROVIDER, input.threadId, "session/set_model", cause), + }); ctx.currentModelId = currentModelId; + if (requestedTurnReasoningEffort !== undefined) { + ctx.currentReasoningEffort = normalizeGrokReasoningEffort( + requestedTurnReasoningEffort, + ); + } const displayModel = currentModelId ? resolveGrokAcpBaseModelId(currentModelId) : undefined; @@ -1032,6 +1595,11 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte updatedAt: yield* nowIso, ...(displayModel ? { model: displayModel } : {}), }; + if (steeringTurnId === undefined) { + yield* beginTurnLiveness(ctx, turnId); + } else { + yield* refreshSessionTurnLiveness(input.threadId, turnId); + } if (steeringTurnId === undefined) { yield* offerRuntimeEvent({ @@ -1082,10 +1650,14 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte }) .pipe( Effect.tap((promptResult) => - Effect.all([ - Ref.set(promptRpcSucceeded, true), - Ref.set(promptResultRef, promptResult), - ]), + Effect.all( + [ + Ref.set(promptRpcSucceeded, true), + Ref.set(promptResultRef, promptResult), + markPromptResponseReady(input.threadId, prepared.acpSessionId, prepared.turnId), + ], + { discard: true }, + ), ), Effect.tapError((error) => Ref.set( @@ -1126,6 +1698,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte yield* Effect.yieldNow; } yield* prepared.acp.drainEvents; + consumePromptResponseReady(ctx); if (ctx.interruptedTurnIds.has(prepared.turnId)) { yield* Ref.set(promptSettled, true); return { @@ -1184,6 +1757,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte updatedAt: completedAt, ...(prepared.displayModel ? { model: prepared.displayModel } : {}), }; + yield* clearTurnLiveness(ctx); const completedStopReason = completedStopReasonFromPromptResponse(result); yield* offerRuntimeEvent({ type: "turn.completed", @@ -1240,6 +1814,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte if (ctx.interruptedTurnIds.has(prepared.turnId)) { return; } + consumePromptResponseReady(ctx); if ( ctx.promptsInFlight <= 0 || ctx.activeTurnId !== prepared.turnId || @@ -1356,6 +1931,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte status: "ready", updatedAt, }; + yield* clearTurnLiveness(ctx); } }), ); diff --git a/apps/server/src/provider/Layers/GrokProvider.test.ts b/apps/server/src/provider/Layers/GrokProvider.test.ts index 1c9bf1f26de7..307f55eeb5a8 100644 --- a/apps/server/src/provider/Layers/GrokProvider.test.ts +++ b/apps/server/src/provider/Layers/GrokProvider.test.ts @@ -6,10 +6,183 @@ import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import { GrokSettings } from "@t3tools/contracts"; -import { buildInitialGrokProviderSnapshot, checkGrokProviderStatus } from "./GrokProvider.ts"; +import { + buildGrokModelCapabilities, + buildInitialGrokProviderSnapshot, + checkGrokProviderStatus, +} from "./GrokProvider.ts"; const decodeGrokSettings = Schema.decodeSync(GrokSettings); +describe("buildGrokModelCapabilities", () => { + it("preserves ACP-provided reasoning labels and the active default", () => { + const capabilities = buildGrokModelCapabilities({ + modelId: "grok-4.6", + name: "Grok 4.6", + _meta: { + supportsReasoningEffort: true, + reasoningEffort: "xhigh", + reasoningEfforts: [ + { value: "xhigh", label: "Extra High Effort", default: true }, + { value: "high", label: "High Effort", default: true }, + { value: "medium", label: "Medium Effort" }, + { value: "low", label: "Low Effort" }, + ], + }, + }); + + expect(capabilities.optionDescriptors).toEqual([ + { + id: "reasoningEffort", + label: "Reasoning", + type: "select", + currentValue: "xhigh", + options: [ + { id: "xhigh", label: "Extra High Effort", isDefault: true }, + { id: "high", label: "High Effort" }, + { id: "medium", label: "Medium Effort" }, + { id: "low", label: "Low Effort" }, + ], + }, + ]); + }); + + it("uses raw ACP values when option labels are omitted", () => { + const capabilities = buildGrokModelCapabilities({ + modelId: "grok-4.6", + name: "Grok 4.6", + _meta: { + supportsReasoningEffort: true, + reasoningEffort: "xhigh", + reasoningEfforts: [{ value: "xhigh" }, { value: "medium" }], + }, + }); + + expect(capabilities.optionDescriptors).toEqual([ + { + id: "reasoningEffort", + label: "Reasoning", + type: "select", + currentValue: "xhigh", + options: [ + { id: "xhigh", label: "xhigh" }, + { id: "medium", label: "medium" }, + ], + }, + ]); + }); + + it("keeps ACP current effort separate from its collapsed advertised default", () => { + const capabilities = buildGrokModelCapabilities({ + modelId: "grok-4.6", + name: "Grok 4.6", + _meta: { + supportsReasoningEffort: true, + reasoningEffort: "medium", + reasoningEfforts: [ + { value: "xhigh", label: "Extra High Effort", default: true }, + { value: "high", label: "High Effort", default: true }, + { value: "medium", label: "Medium Effort" }, + ], + }, + }); + + expect(capabilities.optionDescriptors).toEqual([ + { + id: "reasoningEffort", + label: "Reasoning", + type: "select", + currentValue: "medium", + options: [ + { id: "xhigh", label: "Extra High Effort", isDefault: true }, + { id: "high", label: "High Effort" }, + { id: "medium", label: "Medium Effort" }, + ], + }, + ]); + }); + + it("preserves ACP descriptions and falls back from invalid values to valid ids", () => { + const capabilities = buildGrokModelCapabilities({ + modelId: "grok-4.6", + name: "Grok 4.6", + _meta: { + supportsReasoningEffort: true, + reasoningEffort: "high", + reasoningEfforts: [ + { + id: "high", + value: "not a token", + label: "High Effort", + description: "Higher implementation quality", + default: true, + }, + { id: "bad id", value: "also invalid", label: "Invalid" }, + ], + }, + }); + + expect(capabilities.optionDescriptors).toEqual([ + { + id: "reasoningEffort", + label: "Reasoning", + type: "select", + currentValue: "high", + options: [ + { + id: "high", + label: "High Effort", + description: "Higher implementation quality", + isDefault: true, + }, + ], + }, + ]); + }); + + it("accepts an advertised ACP menu when the support flag is omitted", () => { + const capabilities = buildGrokModelCapabilities({ + modelId: "grok-4.6", + name: "Grok 4.6", + _meta: { + reasoningEffort: "high", + reasoningEfforts: [{ value: "high", label: "High Effort", default: true }], + }, + }); + + expect(capabilities.optionDescriptors).toHaveLength(1); + }); + + it("honors an explicit ACP opt-out even when a menu is present", () => { + const capabilities = buildGrokModelCapabilities({ + modelId: "grok-4.6", + name: "Grok 4.6", + _meta: { + supportsReasoningEffort: false, + reasoningEfforts: [{ value: "high", label: "High Effort", default: true }], + }, + }); + + expect(capabilities.optionDescriptors).toEqual([]); + }); + + it("does not synthesize a reasoning menu when ACP omits it", () => { + expect( + buildGrokModelCapabilities({ + modelId: "grok-4.6", + name: "Grok 4.6", + _meta: { supportsReasoningEffort: true, reasoningEffort: "xhigh" }, + }).optionDescriptors, + ).toEqual([]); + }); + + it("keeps non-reasoning Grok models free of reasoning controls", () => { + expect( + buildGrokModelCapabilities({ modelId: "grok-build", name: "Grok Build" }).optionDescriptors, + ).toEqual([]); + }); +}); + describe("buildInitialGrokProviderSnapshot", () => { it.effect("returns a disabled snapshot when settings.enabled is false", () => Effect.gen(function* () { @@ -41,7 +214,7 @@ describe("buildInitialGrokProviderSnapshot", () => { expect(snapshot.status).toBe("warning"); expect(snapshot.version).toBeNull(); expect(snapshot.message).toContain("Checking Grok"); - expect(snapshot.requiresNewThreadForModelChange).toBe(true); + expect(snapshot.requiresNewThreadForModelChange).toBeUndefined(); }), ); }); diff --git a/apps/server/src/provider/Layers/GrokProvider.ts b/apps/server/src/provider/Layers/GrokProvider.ts index 934eecdb5ae6..923e1781711d 100644 --- a/apps/server/src/provider/Layers/GrokProvider.ts +++ b/apps/server/src/provider/Layers/GrokProvider.ts @@ -29,13 +29,17 @@ import { enrichProviderSnapshotWithVersionAdvisory, type ProviderMaintenanceCapabilities, } from "../providerMaintenance.ts"; -import { makeGrokAcpRuntime, resolveGrokAcpBaseModelId } from "../acp/GrokAcpSupport.ts"; +import { + isValidGrokReasoningEffortToken, + makeGrokAcpRuntime, + resolveGrokAcpBaseModelId, +} from "../acp/GrokAcpSupport.ts"; +import { discoverGrokSkills } from "../Drivers/GrokSkills.ts"; const GROK_PRESENTATION = { displayName: "Grok", badgeLabel: "Early Access", showInteractionModeToggle: false, - requiresNewThreadForModelChange: true, } as const; const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ optionDescriptors: [], @@ -99,6 +103,104 @@ function grokModelsFromSettings( return providerModelsFromSettings(builtInModels, customModels ?? [], EMPTY_CAPABILITIES); } +function nonEmptyString(value: unknown): string | undefined { + return typeof value === "string" ? value.trim() || undefined : undefined; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function grokReasoningOptionsFromModel(model: EffectAcpSchema.ModelInfo): { + readonly options: ReadonlyArray<{ + value: string; + label: string; + description?: string; + isDefault?: boolean; + }>; + readonly currentValue: string | undefined; +} { + const meta = model._meta; + if (!meta || meta.supportsReasoningEffort === false) { + return { options: [], currentValue: undefined }; + } + + const currentEffort = nonEmptyString(meta.reasoningEffort); + const advertisedOptions = Array.isArray(meta.reasoningEfforts) ? meta.reasoningEfforts : []; + const seen = new Set(); + const options: Array<{ + value: string; + label: string; + description?: string; + advertisedDefault: boolean; + }> = []; + + for (const entry of advertisedOptions) { + if (!isRecord(entry)) { + continue; + } + const rawValue = nonEmptyString(entry.value); + const rawId = nonEmptyString(entry.id); + const value = + rawValue && isValidGrokReasoningEffortToken(rawValue) + ? rawValue + : rawId && isValidGrokReasoningEffortToken(rawId) + ? rawId + : undefined; + if (value === undefined || seen.has(value)) { + continue; + } + seen.add(value); + const description = nonEmptyString(entry.description); + options.push({ + value, + label: nonEmptyString(entry.label) ?? value, + ...(description ? { description } : {}), + advertisedDefault: entry.default === true || entry.isDefault === true, + }); + } + + const currentValue = + currentEffort && options.some((option) => option.value === currentEffort) + ? currentEffort + : undefined; + const advertisedDefaults = options.filter((option) => option.advertisedDefault); + const selectedDefault = + advertisedDefaults.find((option) => option.value === currentValue)?.value ?? + advertisedDefaults[0]?.value; + return { + options: options.map(({ value, label, description }) => ({ + value, + label, + ...(description ? { description } : {}), + ...(value === selectedDefault ? { isDefault: true } : {}), + })), + currentValue: currentValue ?? selectedDefault, + }; +} + +export function buildGrokModelCapabilities(model: EffectAcpSchema.ModelInfo): ModelCapabilities { + const reasoning = grokReasoningOptionsFromModel(model); + return reasoning.options.length > 0 + ? createModelCapabilities({ + optionDescriptors: [ + { + id: "reasoningEffort", + label: "Reasoning", + type: "select", + options: reasoning.options.map((option) => ({ + id: option.value, + label: option.label, + ...(option.description ? { description: option.description } : {}), + ...(option.isDefault ? { isDefault: true } : {}), + })), + ...(reasoning.currentValue ? { currentValue: reasoning.currentValue } : {}), + }, + ], + }) + : EMPTY_CAPABILITIES; +} + function buildGrokDiscoveredModelsFromSessionModelState( modelState: EffectAcpSchema.SessionModelState | null | undefined, ): ReadonlyArray { @@ -117,7 +219,7 @@ function buildGrokDiscoveredModelsFromSessionModelState( slug, name: model.name.trim() || slug, isCustom: false, - capabilities: EMPTY_CAPABILITIES, + capabilities: buildGrokModelCapabilities(model), }; }) .filter((model): model is ServerProviderModel => model !== undefined); @@ -161,6 +263,7 @@ const runGrokVersionCommand = ( export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(function* ( grokSettings: GrokSettings, environment: NodeJS.ProcessEnv = process.env, + cwd?: string, ): Effect.fn.Return< ServerProviderDraft, never, @@ -251,6 +354,8 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func }); } + const skills = yield* discoverGrokSkills(grokSettings, environment, cwd); + const discoveryExit = yield* discoverGrokModelsViaAcp(grokSettings, environment).pipe( Effect.timeoutOption(GROK_ACP_MODEL_DISCOVERY_TIMEOUT_MS), Effect.exit, @@ -264,6 +369,7 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func enabled: grokSettings.enabled, checkedAt, models: fallbackModels, + skills, probe: { installed: true, version, @@ -282,6 +388,7 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func enabled: grokSettings.enabled, checkedAt, models: fallbackModels, + skills, probe: { installed: true, version, @@ -302,6 +409,7 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func enabled: grokSettings.enabled, checkedAt, models, + skills, probe: { installed: true, version, diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index eea328e05d1e..d297360e6d34 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -1,6 +1,7 @@ import * as NodeAssert from "node:assert/strict"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; +import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; @@ -14,8 +15,10 @@ import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; import { beforeEach } from "vite-plus/test"; +import type { PermissionRequest, QuestionRequest } from "@opencode-ai/sdk/v2"; import { + ApprovalRequestId, OpenCodeSettings, ProviderDriverKind, ProviderInstanceId, @@ -59,19 +62,53 @@ const runtimeMock = { startCalls: [] as string[], sessionCreateUrls: [] as string[], sessionCreateInputs: [] as Array>, + createdSessionIds: [] as string[], authHeaders: [] as Array, abortCalls: [] as string[], + abortSignals: [] as AbortSignal[], + abortImplementation: null as + | ((sessionID: string, signal?: AbortSignal) => Promise) + | null, + sessionChildrenCalls: [] as string[], + sessionChildrenById: new Map>(), + sessionChildrenImplementation: null as + | ((sessionID: string) => Promise>) + | null, closeCalls: [] as string[], revertCalls: [] as Array<{ sessionID: string; messageID?: string }>, + messageCalls: [] as Array<{ sessionID: string; messageID: string }>, + messageFailures: 0, promptCalls: [] as Array, promptAsyncError: null as Error | null, + promptAsyncImplementation: null as (() => Promise) | null, + autoPromptEcho: true, + autoConnect: true, + promptEchoEvents: [] as Array, closeError: null as Error | null, messages: [] as MessageEntry[], - subscribedEvents: [] as unknown[], + subscribedEvents: [] as Array>, + eventSubscribeObserved: null as (() => void) | null, + permissionReplyCalls: [] as Array<{ requestID: string; reply: string }>, + questionReplyCalls: [] as Array<{ + requestID: string; + answers: ReadonlyArray>; + }>, + sessionStatus: "idle" as "idle" | "busy", + sessionStatusFailures: 0, + sessionStatusCalls: 0, + sessionStatusImplementation: null as (() => Promise) | null, sessionGetIds: [] as string[], + sessionGetObserved: null as ((sessionID: string) => void) | null, missingSessionIds: new Set(), transientErrorSessionIds: new Set(), sessionDirectoryById: new Map(), + sessionParentById: new Map(), + pendingPermissions: [] as Array, + pendingQuestions: [] as Array, + permissionListCalls: 0, + questionListCalls: 0, + permissionListImplementation: null as (() => Promise>) | null, + questionListImplementation: null as (() => Promise>) | null, sessionUpdateCalls: [] as Array<{ sessionID: string; permission: unknown }>, forkCalls: [] as Array<{ sessionID: string; directory?: string }>, }, @@ -79,26 +116,53 @@ const runtimeMock = { this.state.startCalls.length = 0; this.state.sessionCreateUrls.length = 0; this.state.sessionCreateInputs.length = 0; + this.state.createdSessionIds.length = 0; this.state.authHeaders.length = 0; this.state.abortCalls.length = 0; + this.state.abortSignals.length = 0; + this.state.abortImplementation = null; + this.state.sessionChildrenCalls.length = 0; + this.state.sessionChildrenById.clear(); + this.state.sessionChildrenImplementation = null; this.state.closeCalls.length = 0; this.state.revertCalls.length = 0; + this.state.messageCalls.length = 0; + this.state.messageFailures = 0; this.state.promptCalls.length = 0; this.state.promptAsyncError = null; + this.state.promptAsyncImplementation = null; + this.state.autoPromptEcho = true; + this.state.autoConnect = true; + this.state.promptEchoEvents.length = 0; this.state.closeError = null; this.state.messages = []; this.state.subscribedEvents = []; + this.state.eventSubscribeObserved = null; + this.state.permissionReplyCalls.length = 0; + this.state.questionReplyCalls.length = 0; + this.state.sessionStatus = "idle"; + this.state.sessionStatusFailures = 0; + this.state.sessionStatusCalls = 0; + this.state.sessionStatusImplementation = null; this.state.sessionGetIds.length = 0; + this.state.sessionGetObserved = null; this.state.missingSessionIds.clear(); this.state.transientErrorSessionIds.clear(); this.state.sessionDirectoryById.clear(); + this.state.sessionParentById.clear(); + this.state.pendingPermissions = []; + this.state.pendingQuestions = []; + this.state.permissionListCalls = 0; + this.state.questionListCalls = 0; + this.state.permissionListImplementation = null; + this.state.questionListImplementation = null; this.state.sessionUpdateCalls.length = 0; this.state.forkCalls.length = 0; }, }; const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { - startOpenCodeServerProcess: ({ binaryPath }) => + startOpenCodeServerProcess: ({ binaryPath, serverPassword }) => Effect.gen(function* () { runtimeMock.state.startCalls.push(binaryPath); const url = "http://127.0.0.1:4301"; @@ -112,10 +176,13 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { ); return { url, + version: "1.15.13", + ...(serverPassword ? { serverPassword } : {}), exitCode: Effect.never, + isRunning: Effect.succeed(true), }; }), - connectToOpenCodeServer: ({ serverUrl }) => + connectToOpenCodeServer: ({ serverUrl, serverPassword }) => Effect.gen(function* () { const url = serverUrl ?? "http://127.0.0.1:4301"; // Always register a finalizer so the closeCalls/closeError probes fire; @@ -130,6 +197,8 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { ); return { url, + version: "1.15.13", + ...(serverPassword ? { serverPassword } : {}), exitCode: null, external: Boolean(serverUrl), }; @@ -144,10 +213,13 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { runtimeMock.state.authHeaders.push( serverPassword ? `Basic ${btoa(`opencode:${serverPassword}`)}` : null, ); - return { data: { id: `${baseUrl}/session` } }; + return { + data: { id: runtimeMock.state.createdSessionIds.shift() ?? `${baseUrl}/session` }, + }; }, get: async ({ sessionID }: { sessionID: string }) => { runtimeMock.state.sessionGetIds.push(sessionID); + runtimeMock.state.sessionGetObserved?.(sessionID); // The real client is `throwOnError: true`: non-2xx rejects rather // than resolving, so missing → 404 throw, transient → 500 throw. if (runtimeMock.state.transientErrorSessionIds.has(sessionID)) { @@ -159,7 +231,14 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { }); } const directory = runtimeMock.state.sessionDirectoryById.get(sessionID); - return { data: { id: sessionID, ...(directory ? { directory } : {}) } }; + const parentID = runtimeMock.state.sessionParentById.get(sessionID); + return { + data: { + id: sessionID, + ...(directory ? { directory } : {}), + ...(parentID ? { parentID } : {}), + }, + }; }, update: async ({ sessionID, permission }: { sessionID: string; permission: unknown }) => { runtimeMock.state.sessionUpdateCalls.push({ sessionID, permission }); @@ -174,16 +253,81 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { } return { data: { id: forkedId, ...(directory ? { directory } : {}) } }; }, - abort: async ({ sessionID }: { sessionID: string }) => { + abort: async ({ sessionID }: { sessionID: string }, options?: { signal?: AbortSignal }) => { runtimeMock.state.abortCalls.push(sessionID); + if (options?.signal) { + runtimeMock.state.abortSignals.push(options.signal); + } + await runtimeMock.state.abortImplementation?.(sessionID, options?.signal); + }, + children: async ({ sessionID }: { sessionID: string }) => { + runtimeMock.state.sessionChildrenCalls.push(sessionID); + return { + data: runtimeMock.state.sessionChildrenImplementation + ? await runtimeMock.state.sessionChildrenImplementation(sessionID) + : (runtimeMock.state.sessionChildrenById.get(sessionID) ?? []), + }; + }, + status: async () => { + runtimeMock.state.sessionStatusCalls += 1; + if (runtimeMock.state.sessionStatusImplementation) { + return await runtimeMock.state.sessionStatusImplementation(); + } + if (runtimeMock.state.sessionStatusFailures > 0) { + runtimeMock.state.sessionStatusFailures -= 1; + throw new Error("status failed"); + } + return { + data: + runtimeMock.state.sessionStatus === "idle" + ? {} + : { "http://127.0.0.1:9999/session": { type: "busy" as const } }, + }; }, promptAsync: async (input: unknown) => { runtimeMock.state.promptCalls.push(input); + await runtimeMock.state.promptAsyncImplementation?.(); if (runtimeMock.state.promptAsyncError) { throw runtimeMock.state.promptAsyncError; } + if ( + runtimeMock.state.autoPromptEcho && + typeof input === "object" && + input !== null && + "sessionID" in input && + "messageID" in input && + typeof input.sessionID === "string" && + typeof input.messageID === "string" + ) { + runtimeMock.state.messages.push({ + info: { id: input.messageID, role: "user" }, + parts: [], + }); + runtimeMock.state.promptEchoEvents.push({ + id: `evt-auto-user-${input.messageID}`, + type: "message.updated", + properties: { + sessionID: input.sessionID, + info: { id: input.messageID, role: "user" }, + }, + }); + } }, messages: async () => ({ data: runtimeMock.state.messages }), + message: async ({ sessionID, messageID }: { sessionID: string; messageID: string }) => { + runtimeMock.state.messageCalls.push({ sessionID, messageID }); + if (runtimeMock.state.messageFailures > 0) { + runtimeMock.state.messageFailures -= 1; + throw new Error("message lookup failed", { cause: { status: 500 } }); + } + const message = runtimeMock.state.messages.find((entry) => entry.info.id === messageID); + if (!message) { + throw new Error(`Message not found: ${messageID}`, { + cause: { status: 404, body: { name: "NotFoundError" } }, + }); + } + return { data: message }; + }, revert: async ({ sessionID, messageID }: { sessionID: string; messageID?: string }) => { runtimeMock.state.revertCalls.push({ sessionID, @@ -204,13 +348,55 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { }, }, event: { - subscribe: async () => ({ - stream: (async function* () { - for (const event of runtimeMock.state.subscribedEvents) { - yield event; - } - })(), - }), + subscribe: async () => { + runtimeMock.state.eventSubscribeObserved?.(); + return { + stream: (async function* () { + if (runtimeMock.state.autoConnect) { + yield { id: "evt-auto-connected", type: "server.connected", properties: {} }; + } + for (const event of runtimeMock.state.subscribedEvents) { + const resolved = await event; + while (runtimeMock.state.promptEchoEvents.length > 0) { + yield runtimeMock.state.promptEchoEvents.shift(); + } + yield resolved; + } + })(), + }; + }, + }, + permission: { + list: async () => { + runtimeMock.state.permissionListCalls += 1; + return { + data: runtimeMock.state.permissionListImplementation + ? await runtimeMock.state.permissionListImplementation() + : runtimeMock.state.pendingPermissions, + }; + }, + reply: async ({ requestID, reply }: { requestID: string; reply: string }) => { + runtimeMock.state.permissionReplyCalls.push({ requestID, reply }); + }, + }, + question: { + list: async () => { + runtimeMock.state.questionListCalls += 1; + return { + data: runtimeMock.state.questionListImplementation + ? await runtimeMock.state.questionListImplementation() + : runtimeMock.state.pendingQuestions, + }; + }, + reply: async ({ + requestID, + answers, + }: { + requestID: string; + answers: ReadonlyArray>; + }) => { + runtimeMock.state.questionReplyCalls.push({ requestID, answers }); + }, }, }) as unknown as ReturnType, loadOpenCodeInventory: () => @@ -280,6 +466,37 @@ beforeEach(() => { const advanceTestClock = (ms: number) => TestClock.adjust(`${ms} millis`).pipe(Effect.andThen(Effect.yieldNow)); +function promiseWithResolvers() { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + +const permissionRequest = (id: string, sessionID: string): PermissionRequest => ({ + id, + sessionID, + permission: "bash", + patterns: ["pwd"], + metadata: {}, + always: [], +}); + +const questionRequest = (id: string, sessionID: string): QuestionRequest => ({ + id, + sessionID, + questions: [ + { + header: "Scope", + question: "Which scope should OpenCode use?", + options: [{ label: "Workspace", description: "Use this workspace." }], + }, + ], +}); + it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { it.effect("reuses a configured OpenCode server URL instead of spawning a local server", () => Effect.gen(function* () { @@ -301,6 +518,348 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { }), ); + it.effect("fails startup when the OpenCode event stream does not connect", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-connect-timeout"); + runtimeMock.state.autoConnect = false; + + const startFiber = yield* adapter + .startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }) + .pipe(Effect.result, Effect.forkChild); + yield* Effect.yieldNow; + yield* advanceTestClock(10_000); + + const result = yield* Fiber.join(startFiber); + NodeAssert.equal(result._tag, "Failure"); + NodeAssert.equal(result.failure._tag, "ProviderAdapterRequestError"); + NodeAssert.equal(result.failure.method, "event.subscribe"); + NodeAssert.equal(yield* adapter.hasSession(threadId), false); + }), + ); + + it.effect("closes a connecting session when startup is interrupted", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-connect-interrupted"); + const eventSubscribeObserved = promiseWithResolvers(); + runtimeMock.state.autoConnect = false; + runtimeMock.state.eventSubscribeObserved = () => eventSubscribeObserved.resolve(undefined); + + const startFiber = yield* adapter + .startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }) + .pipe(Effect.forkChild); + yield* Effect.promise(() => eventSubscribeObserved.promise); + yield* Effect.yieldNow; + yield* Fiber.interrupt(startFiber); + + NodeAssert.deepEqual(runtimeMock.state.closeCalls, ["http://127.0.0.1:9999"]); + NodeAssert.deepEqual(runtimeMock.state.abortCalls, ["http://127.0.0.1:9999/session"]); + NodeAssert.equal(yield* adapter.hasSession(threadId), false); + }), + ); + + it.effect("stops a connecting session and rejects its waiting send", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-stop-connecting"); + const eventSubscribeObserved = promiseWithResolvers(); + runtimeMock.state.autoConnect = false; + runtimeMock.state.eventSubscribeObserved = () => eventSubscribeObserved.resolve(undefined); + + const startFiber = yield* adapter + .startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }) + .pipe(Effect.result, Effect.forkChild); + yield* Effect.promise(() => eventSubscribeObserved.promise); + const connecting = (yield* adapter.listSessions()).find( + (session) => session.threadId === threadId, + ); + NodeAssert.equal(connecting?.status, "connecting"); + + const sendFiber = yield* adapter + .sendTurn({ + threadId, + input: "Must not be sent", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }) + .pipe(Effect.exit, Effect.forkChild); + yield* Effect.yieldNow; + NodeAssert.equal(runtimeMock.state.promptCalls.length, 0); + + yield* adapter.stopSession(threadId); + const startResult = yield* Fiber.join(startFiber); + const sendResult = yield* Fiber.join(sendFiber); + NodeAssert.equal(startResult._tag, "Failure"); + NodeAssert.equal(sendResult._tag, "Failure"); + NodeAssert.equal(runtimeMock.state.promptCalls.length, 0); + NodeAssert.deepEqual(runtimeMock.state.closeCalls, ["http://127.0.0.1:9999"]); + NodeAssert.equal(yield* adapter.hasSession(threadId), false); + }), + ); + + it.effect("aborts a held teardown request before closing the session scope", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-teardown-timeout"); + const abortStarted = promiseWithResolvers(); + runtimeMock.state.abortImplementation = async () => { + abortStarted.resolve(undefined); + await new Promise(() => {}); + }; + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + const stopFiber = yield* adapter.stopSession(threadId).pipe(Effect.forkChild); + yield* Effect.promise(() => abortStarted.promise); + + yield* advanceTestClock(999); + NodeAssert.equal(stopFiber.pollUnsafe(), undefined); + NodeAssert.equal(runtimeMock.state.abortSignals.length, 1); + NodeAssert.equal(runtimeMock.state.abortSignals[0]?.aborted, false); + NodeAssert.deepEqual(runtimeMock.state.closeCalls, []); + + yield* advanceTestClock(1); + yield* Fiber.join(stopFiber); + NodeAssert.equal(runtimeMock.state.abortSignals[0]?.aborted, true); + NodeAssert.deepEqual(runtimeMock.state.closeCalls, ["http://127.0.0.1:9999"]); + NodeAssert.equal(yield* adapter.hasSession(threadId), false); + }), + ); + + it.effect("stopAll closes a connecting session and releases startup", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-stop-all-connecting"); + const eventSubscribeObserved = promiseWithResolvers(); + runtimeMock.state.autoConnect = false; + runtimeMock.state.eventSubscribeObserved = () => eventSubscribeObserved.resolve(undefined); + + const startFiber = yield* adapter + .startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }) + .pipe(Effect.result, Effect.forkChild); + yield* Effect.promise(() => eventSubscribeObserved.promise); + const sessionCount = (yield* adapter.listSessions()).length; + + yield* adapter.stopAll(); + const startResult = yield* Fiber.join(startFiber); + NodeAssert.equal(startResult._tag, "Failure"); + NodeAssert.equal(runtimeMock.state.closeCalls.length, sessionCount); + NodeAssert.equal(yield* adapter.hasSession(threadId), false); + }), + ); + + it.effect("keeps one session when concurrent starts cross the connection barrier", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-concurrent-start"); + const connectionEvent = promiseWithResolvers(); + runtimeMock.state.autoConnect = false; + runtimeMock.state.createdSessionIds.push("ses_race_a", "ses_race_b"); + runtimeMock.state.subscribedEvents = [connectionEvent.promise]; + + const firstStart = yield* adapter + .startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }) + .pipe(Effect.forkChild); + const secondStart = yield* adapter + .startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }) + .pipe(Effect.forkChild); + yield* Effect.yieldNow; + connectionEvent.resolve({ + id: "evt-concurrent-start-connected", + type: "server.connected", + properties: {}, + }); + + const [firstSession, secondSession] = yield* Effect.all([ + Fiber.join(firstStart), + Fiber.join(secondStart), + ]); + const sessions = yield* adapter.listSessions(); + const threadSessions = sessions.filter((session) => session.threadId === threadId); + NodeAssert.equal(threadSessions.length, 1); + NodeAssert.deepEqual(firstSession.resumeCursor, secondSession.resumeCursor); + NodeAssert.equal(firstSession.status, "ready"); + NodeAssert.equal(secondSession.status, "ready"); + const winnerId = (threadSessions[0]?.resumeCursor as { sessionId?: string } | undefined) + ?.sessionId; + NodeAssert.ok(winnerId === "ses_race_a" || winnerId === "ses_race_b"); + NodeAssert.deepEqual(runtimeMock.state.abortCalls, [ + winnerId === "ses_race_a" ? "ses_race_b" : "ses_race_a", + ]); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("reuses a published connecting session after it becomes ready", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-reuse-connecting"); + const connectionEvent = promiseWithResolvers(); + const eventSubscribeObserved = promiseWithResolvers(); + runtimeMock.state.autoConnect = false; + runtimeMock.state.eventSubscribeObserved = () => eventSubscribeObserved.resolve(undefined); + runtimeMock.state.subscribedEvents = [connectionEvent.promise]; + + const owningStart = yield* adapter + .startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }) + .pipe(Effect.forkChild); + yield* Effect.promise(() => eventSubscribeObserved.promise); + const reusedStart = yield* adapter + .startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }) + .pipe(Effect.forkChild); + yield* Effect.yieldNow; + NodeAssert.equal(runtimeMock.state.sessionCreateUrls.length, 1); + + connectionEvent.resolve({ + id: "evt-reused-start-connected", + type: "server.connected", + properties: {}, + }); + const [ownedSession, reusedSession] = yield* Effect.all([ + Fiber.join(owningStart), + Fiber.join(reusedStart), + ]); + NodeAssert.equal(ownedSession.status, "ready"); + NodeAssert.equal(reusedSession.status, "ready"); + NodeAssert.deepEqual(ownedSession.resumeCursor, reusedSession.resumeCursor); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("does not let an old held stop delete its replacement", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-old-stop-replacement"); + const abortStarted = promiseWithResolvers(); + const abortRelease = promiseWithResolvers(); + runtimeMock.state.createdSessionIds.push("ses_old", "ses_replacement"); + + const oldSession = yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + runtimeMock.state.abortImplementation = async () => { + abortStarted.resolve(undefined); + await abortRelease.promise; + }; + const oldStop = yield* adapter.stopSession(threadId).pipe(Effect.forkChild); + yield* Effect.promise(() => abortStarted.promise); + + const replacement = yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + NodeAssert.deepEqual(oldSession.resumeCursor, { schemaVersion: 1, sessionId: "ses_old" }); + NodeAssert.deepEqual(replacement.resumeCursor, { + schemaVersion: 1, + sessionId: "ses_replacement", + }); + + abortRelease.resolve(undefined); + yield* Fiber.join(oldStop); + const current = (yield* adapter.listSessions()).find( + (session) => session.threadId === threadId, + ); + NodeAssert.deepEqual(current?.resumeCursor, replacement.resumeCursor); + + runtimeMock.state.abortImplementation = null; + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("replaces a stopped connecting session while its teardown is held", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-stopped-connecting-retry"); + const eventSubscribeObserved = promiseWithResolvers(); + const abortStarted = promiseWithResolvers(); + const abortRelease = promiseWithResolvers(); + runtimeMock.state.autoConnect = false; + runtimeMock.state.eventSubscribeObserved = () => eventSubscribeObserved.resolve(undefined); + runtimeMock.state.createdSessionIds.push("ses_connecting_old", "ses_connecting_replacement"); + + const oldStart = yield* adapter + .startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }) + .pipe(Effect.result, Effect.forkChild); + yield* Effect.promise(() => eventSubscribeObserved.promise); + + runtimeMock.state.abortImplementation = async () => { + abortStarted.resolve(undefined); + await abortRelease.promise; + }; + const oldStop = yield* adapter.stopSession(threadId).pipe(Effect.forkChild); + yield* Effect.promise(() => abortStarted.promise); + + runtimeMock.state.autoConnect = true; + runtimeMock.state.abortImplementation = null; + const replacement = yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + NodeAssert.equal(replacement.status, "ready"); + NodeAssert.deepEqual(replacement.resumeCursor, { + schemaVersion: 1, + sessionId: "ses_connecting_replacement", + }); + + abortRelease.resolve(undefined); + const oldStartResult = yield* Fiber.join(oldStart); + yield* Fiber.join(oldStop); + NodeAssert.equal(oldStartResult._tag, "Failure"); + const current = (yield* adapter.listSessions()).find( + (session) => session.threadId === threadId, + ); + NodeAssert.deepEqual(current?.resumeCursor, replacement.resumeCursor); + + yield* adapter.stopSession(threadId); + }), + ); + it.effect("returns a durable resume cursor for a freshly created session", () => Effect.gen(function* () { const adapter = yield* OpenCodeAdapter; @@ -586,6 +1145,9 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { it.effect("stops a configured-server session without trying to own server lifecycle", () => Effect.gen(function* () { const adapter = yield* OpenCodeAdapter; + const rootSessionId = "http://127.0.0.1:9999/session"; + runtimeMock.state.sessionChildrenById.set(rootSessionId, [{ id: "ses_stop_child" }]); + runtimeMock.state.sessionChildrenById.set("ses_stop_child", [{ id: "ses_stop_grandchild" }]); yield* adapter.startSession({ provider: ProviderDriverKind.make("opencode"), threadId: asThreadId("thread-opencode"), @@ -595,10 +1157,11 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { yield* adapter.stopSession(asThreadId("thread-opencode")); NodeAssert.deepEqual(runtimeMock.state.startCalls, []); - NodeAssert.deepEqual( - runtimeMock.state.abortCalls.includes("http://127.0.0.1:9999/session"), - true, - ); + NodeAssert.deepEqual(runtimeMock.state.abortCalls, [ + rootSessionId, + "ses_stop_child", + "ses_stop_grandchild", + ]); }), ); @@ -809,88 +1372,3389 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { }), ); - it.effect("passes agent and variant options for the adapter's bound custom instance id", () => { - const instanceId = ProviderInstanceId.make("opencode_zen"); - const adapterLayer = Layer.effect( - OpenCodeAdapter, - makeOpenCodeAdapter(openCodeAdapterTestSettings, { instanceId }), - ).pipe( - Layer.provideMerge(Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble)), - Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), - Layer.provideMerge(ServerSettingsService.layerTest()), - Layer.provideMerge(providerSessionDirectoryTestLayer), - Layer.provideMerge(NodeServices.layer), - ); - - return Effect.gen(function* () { + it.effect("does not let an old idle status complete a successful steer", () => + Effect.gen(function* () { const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-steer-idle-admission"); + const busyBeforeSteer = promiseWithResolvers(); + const idleBeforeSteer = promiseWithResolvers(); + const idleAfterSteer = promiseWithResolvers(); + const statusStarted = promiseWithResolvers(); + const statusRelease = promiseWithResolvers(); + const steerStarted = promiseWithResolvers(); + const steerRelease = promiseWithResolvers(); + runtimeMock.state.subscribedEvents = [ + busyBeforeSteer.promise, + idleBeforeSteer.promise, + idleAfterSteer.promise, + ]; + runtimeMock.state.sessionStatusImplementation = async () => { + statusStarted.resolve(undefined); + await statusRelease.promise; + return { data: {} }; + }; + runtimeMock.state.promptAsyncImplementation = async () => { + if (runtimeMock.state.promptCalls.length === 3) { + steerStarted.resolve(undefined); + await steerRelease.promise; + } + }; + + const completedFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId && event.type === "turn.completed"), + Stream.runHead, + Effect.forkChild, + ); yield* adapter.startSession({ provider: ProviderDriverKind.make("opencode"), - threadId: asThreadId("thread-custom-instance"), + threadId, runtimeMode: "full-access", }); - - yield* adapter.sendTurn({ - threadId: asThreadId("thread-custom-instance"), - input: "Fix it", + const stoppedTurn = yield* adapter.sendTurn({ + threadId, + input: "Stop this turn", modelSelection: createModelSelection( - ProviderInstanceId.make("opencode_zen"), - "anthropic/claude-sonnet-4-5", - [ - { id: "agent", value: "github-copilot" }, - { id: "variant", value: "high" }, - ], + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", ), }); - - NodeAssert.deepEqual(runtimeMock.state.promptCalls.at(-1), { - sessionID: "http://127.0.0.1:9999/session", - model: { - providerID: "anthropic", - modelID: "claude-sonnet-4-5", - }, - agent: "github-copilot", - variant: "high", - parts: [{ type: "text", text: "Fix it" }], - }); - }).pipe(Effect.provide(adapterLayer)); - }); - - it.effect("uses the bound custom instance id for fallback sendTurn model selection", () => { - const instanceId = ProviderInstanceId.make("opencode_zen"); - const adapterLayer = Layer.effect( - OpenCodeAdapter, - makeOpenCodeAdapter(openCodeAdapterTestSettings, { instanceId }), - ).pipe( - Layer.provideMerge(Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble)), - Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), - Layer.provideMerge(ServerSettingsService.layerTest()), - Layer.provideMerge(providerSessionDirectoryTestLayer), - Layer.provideMerge(NodeServices.layer), - ); - - return Effect.gen(function* () { - const adapter = yield* OpenCodeAdapter; - const threadId = asThreadId("thread-custom-instance-fallback-model"); - yield* adapter.startSession({ - provider: ProviderDriverKind.make("opencode"), + yield* adapter.interruptTurn(threadId, stoppedTurn.turnId); + const activeTurn = yield* adapter.sendTurn({ threadId, - runtimeMode: "full-access", + input: "Start the next turn", modelSelection: createModelSelection( - ProviderInstanceId.make("opencode_zen"), - "anthropic/claude-sonnet-4-5", + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", ), }); - - yield* adapter.sendTurn({ - threadId, - input: "Fix it", + busyBeforeSteer.resolve({ + id: "evt-busy-before-steer", + type: "session.status", + properties: { + sessionID: "http://127.0.0.1:9999/session", + status: { type: "busy" }, + }, }); - - NodeAssert.deepEqual(runtimeMock.state.promptCalls.at(-1), { - sessionID: "http://127.0.0.1:9999/session", - model: { - providerID: "anthropic", + idleBeforeSteer.resolve({ + id: "evt-idle-before-steer", + type: "session.status", + properties: { + sessionID: "http://127.0.0.1:9999/session", + status: { type: "idle" }, + }, + }); + yield* Effect.promise(() => statusStarted.promise); + const steerFiber = yield* adapter + .sendTurn({ + threadId, + input: "Add one more task", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }) + .pipe(Effect.forkChild); + yield* Effect.promise(() => steerStarted.promise); + statusRelease.resolve(undefined); + steerRelease.resolve(undefined); + yield* Fiber.join(steerFiber); + + const sessions = yield* adapter.listSessions(); + const session = sessions.find((candidate) => candidate.threadId === threadId); + NodeAssert.equal(session?.status, "running"); + NodeAssert.equal(session?.activeTurnId, activeTurn.turnId); + + idleAfterSteer.resolve({ + id: "evt-idle-after-steer", + type: "session.status", + properties: { + sessionID: "http://127.0.0.1:9999/session", + status: { type: "idle" }, + }, + }); + const completed = Option.getOrUndefined( + yield* Fiber.join(completedFiber).pipe(Effect.timeout("1 second")), + ); + NodeAssert.equal(completed?.turnId, activeTurn.turnId); + }), + ); + + it.effect("waits for steer admission before accepting the only idle event", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-steer-admission-only-idle"); + runtimeMock.state.autoPromptEcho = false; + const firstUserMessageEvent = promiseWithResolvers(); + const staleIdleEvent = promiseWithResolvers(); + const userMessageEvent = promiseWithResolvers(); + const idleEvent = promiseWithResolvers(); + const steerStarted = promiseWithResolvers(); + const steerRelease = promiseWithResolvers(); + runtimeMock.state.subscribedEvents = [ + firstUserMessageEvent.promise, + staleIdleEvent.promise, + userMessageEvent.promise, + idleEvent.promise, + ]; + runtimeMock.state.sessionStatusImplementation = async () => ({ data: {} }); + runtimeMock.state.promptAsyncImplementation = async () => { + if (runtimeMock.state.promptCalls.length === 2) { + steerStarted.resolve(undefined); + await steerRelease.promise; + } + }; + + const completedFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId && event.type === "turn.completed"), + Stream.runHead, + Effect.forkChild, + ); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + const activeTurn = yield* adapter.sendTurn({ + threadId, + input: "Start work", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }); + const steerFiber = yield* adapter + .sendTurn({ + threadId, + input: "Add another task", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }) + .pipe(Effect.forkChild); + yield* Effect.promise(() => steerStarted.promise); + const firstMessageId = (runtimeMock.state.promptCalls[0] as { messageID?: string }).messageID; + const steerMessageId = (runtimeMock.state.promptCalls[1] as { messageID?: string }).messageID; + NodeAssert.match(firstMessageId ?? "", /^msg_[0-9a-f]{12}[0-9A-Za-z]{14}$/); + NodeAssert.match(steerMessageId ?? "", /^msg_[0-9a-f]{12}[0-9A-Za-z]{14}$/); + firstUserMessageEvent.resolve({ + id: "evt-delayed-first-user-message", + type: "message.updated", + properties: { + sessionID: "http://127.0.0.1:9999/session", + info: { id: firstMessageId, role: "user" }, + }, + }); + staleIdleEvent.resolve({ + id: "evt-stale-idle-during-steer", + type: "session.status", + properties: { + sessionID: "http://127.0.0.1:9999/session", + status: { type: "idle" }, + }, + }); + userMessageEvent.resolve({ + id: "evt-steer-user-message", + type: "message.updated", + properties: { + sessionID: "http://127.0.0.1:9999/session", + info: { id: steerMessageId, role: "user" }, + }, + }); + idleEvent.resolve({ + id: "evt-only-idle-during-steer", + type: "session.status", + properties: { + sessionID: "http://127.0.0.1:9999/session", + status: { type: "idle" }, + }, + }); + yield* Effect.yieldNow; + NodeAssert.equal(runtimeMock.state.sessionStatusCalls, 0); + steerRelease.resolve(undefined); + yield* Fiber.join(steerFiber); + + const completed = Option.getOrUndefined( + yield* Fiber.join(completedFiber).pipe(Effect.timeout("1 second")), + ); + NodeAssert.equal(completed?.turnId, activeTurn.turnId); + NodeAssert.equal(runtimeMock.state.sessionStatusCalls > 0, true); + }), + ); + + it.effect("keeps steer admission until its user message arrives after prompt acceptance", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-steer-message-after-acceptance"); + runtimeMock.state.autoPromptEcho = false; + const firstUserMessageEvent = promiseWithResolvers(); + const staleIdleEvent = promiseWithResolvers(); + const steerUserMessageEvent = promiseWithResolvers(); + const validIdleEvent = promiseWithResolvers(); + runtimeMock.state.subscribedEvents = [ + firstUserMessageEvent.promise, + staleIdleEvent.promise, + steerUserMessageEvent.promise, + validIdleEvent.promise, + ]; + + const completedFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId && event.type === "turn.completed"), + Stream.runHead, + Effect.forkChild, + ); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + const activeTurn = yield* adapter.sendTurn({ + threadId, + input: "Start work", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }); + const firstMessageId = (runtimeMock.state.promptCalls[0] as { messageID?: string }).messageID; + firstUserMessageEvent.resolve({ + id: "evt-first-user-message-before-steer", + type: "message.updated", + properties: { + sessionID: "http://127.0.0.1:9999/session", + info: { id: firstMessageId, role: "user" }, + }, + }); + yield* Effect.yieldNow; + + yield* adapter.sendTurn({ + threadId, + input: "Add another task", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }); + const steerMessageId = (runtimeMock.state.promptCalls[1] as { messageID?: string }).messageID; + + staleIdleEvent.resolve({ + id: "evt-stale-idle-after-steer-acceptance", + type: "session.status", + properties: { + sessionID: "http://127.0.0.1:9999/session", + status: { type: "idle" }, + }, + }); + yield* Effect.yieldNow; + const sessionsAfterStaleIdle = yield* adapter.listSessions(); + const sessionAfterStaleIdle = sessionsAfterStaleIdle.find( + (candidate) => candidate.threadId === threadId, + ); + NodeAssert.equal(sessionAfterStaleIdle?.status, "running"); + NodeAssert.equal(sessionAfterStaleIdle?.activeTurnId, activeTurn.turnId); + + steerUserMessageEvent.resolve({ + id: "evt-steer-user-message-after-acceptance", + type: "message.updated", + properties: { + sessionID: "http://127.0.0.1:9999/session", + info: { id: steerMessageId, role: "user" }, + }, + }); + validIdleEvent.resolve({ + id: "evt-valid-idle-after-steer-message", + type: "session.status", + properties: { + sessionID: "http://127.0.0.1:9999/session", + status: { type: "idle" }, + }, + }); + + const completed = Option.getOrUndefined( + yield* Fiber.join(completedFiber).pipe(Effect.timeout("1 second")), + ); + NodeAssert.equal(completed?.turnId, activeTurn.turnId); + }), + ); + + it.effect("recovers steer admission when reconnect happens before prompt acceptance", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-steer-reconnect-before-acceptance"); + const firstUserMessageEvent = promiseWithResolvers(); + const reconnectEvent = promiseWithResolvers(); + const steerStarted = promiseWithResolvers(); + const steerRelease = promiseWithResolvers(); + runtimeMock.state.autoPromptEcho = false; + runtimeMock.state.subscribedEvents = [firstUserMessageEvent.promise, reconnectEvent.promise]; + runtimeMock.state.promptAsyncImplementation = async () => { + if (runtimeMock.state.promptCalls.length === 2) { + steerStarted.resolve(undefined); + await steerRelease.promise; + } + }; + + const completedFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId && event.type === "turn.completed"), + Stream.runHead, + Effect.forkChild, + ); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + const activeTurn = yield* adapter.sendTurn({ + threadId, + input: "Start work", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }); + const firstMessageId = (runtimeMock.state.promptCalls[0] as { messageID?: string }).messageID; + firstUserMessageEvent.resolve({ + id: "evt-first-user-before-reconnect-steer", + type: "message.updated", + properties: { + sessionID: "http://127.0.0.1:9999/session", + info: { id: firstMessageId, role: "user" }, + }, + }); + yield* Effect.yieldNow; + + const steerFiber = yield* adapter + .sendTurn({ + threadId, + input: "Add another task", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }) + .pipe(Effect.forkChild); + yield* Effect.promise(() => steerStarted.promise); + const steerMessageId = (runtimeMock.state.promptCalls[1] as { messageID?: string }).messageID; + NodeAssert.ok(steerMessageId); + runtimeMock.state.messages.push({ + info: { id: steerMessageId, role: "user" }, + parts: [], + }); + runtimeMock.state.messageFailures = 1; + reconnectEvent.resolve({ + id: "evt-reconnected-during-steer", + type: "server.connected", + properties: {}, + }); + yield* Effect.yieldNow; + + steerRelease.resolve(undefined); + yield* Fiber.join(steerFiber); + yield* advanceTestClock(250); + + const completed = Option.getOrUndefined( + yield* Fiber.join(completedFiber).pipe(Effect.timeout("1 second")), + ); + NodeAssert.equal(completed?.turnId, activeTurn.turnId); + NodeAssert.equal( + runtimeMock.state.messageCalls.filter((call) => call.messageID === steerMessageId).length, + 2, + ); + const abortCallsAfterCompletion = runtimeMock.state.abortCalls.length; + yield* adapter.interruptTurn(threadId, activeTurn.turnId); + NodeAssert.equal(runtimeMock.state.abortCalls.length, abortCallsAfterCompletion); + }), + ); + + it.effect("resolves admission without a prompt echo when busy and idle still arrive", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-admission-without-echo"); + const busyEvent = promiseWithResolvers(); + const idleEvent = promiseWithResolvers(); + runtimeMock.state.autoPromptEcho = false; + runtimeMock.state.subscribedEvents = [busyEvent.promise, idleEvent.promise]; + runtimeMock.state.sessionStatusImplementation = async () => ({ data: {} }); + runtimeMock.state.promptAsyncImplementation = async () => { + const prompt = runtimeMock.state.promptCalls.at(-1) as { messageID?: string } | undefined; + if (prompt?.messageID) { + runtimeMock.state.messages.push({ + info: { id: prompt.messageID, role: "user" }, + parts: [], + }); + } + }; + + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + const turn = yield* adapter.sendTurn({ + threadId, + input: "Run without an echo event", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }); + busyEvent.resolve({ + id: "evt-busy-without-echo", + type: "session.status", + properties: { + sessionID: "http://127.0.0.1:9999/session", + status: { type: "busy" }, + }, + }); + idleEvent.resolve({ + id: "evt-idle-without-echo", + type: "session.status", + properties: { + sessionID: "http://127.0.0.1:9999/session", + status: { type: "idle" }, + }, + }); + yield* advanceTestClock(1_000); + + NodeAssert.equal( + runtimeMock.state.messageCalls.some( + (call) => call.messageID === runtimeMock.state.messages[0]?.info.id, + ), + true, + ); + NodeAssert.equal(runtimeMock.state.sessionStatusCalls > 0, true); + const sessions = yield* adapter.listSessions(); + const session = sessions.find((candidate) => candidate.threadId === threadId); + NodeAssert.equal(session?.status, "ready"); + NodeAssert.equal(session?.activeTurnId, undefined); + NodeAssert.equal(turn.turnId !== undefined, true); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("uses polled busy status to admit output after a stopped turn", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-polled-busy-after-stop"); + const firstUserMessageEvent = promiseWithResolvers(); + const assistantMessageEvent = promiseWithResolvers(); + const assistantPartEvent = promiseWithResolvers(); + const idleEvent = promiseWithResolvers(); + const busyStatusPolled = promiseWithResolvers(); + runtimeMock.state.autoPromptEcho = false; + runtimeMock.state.subscribedEvents = [ + firstUserMessageEvent.promise, + assistantMessageEvent.promise, + assistantPartEvent.promise, + idleEvent.promise, + ]; + + const eventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter( + (event) => + event.threadId === threadId && + (event.type === "content.delta" || event.type === "turn.completed"), + ), + Stream.take(2), + Stream.runCollect, + Effect.forkChild, + ); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + const stoppedTurn = yield* adapter.sendTurn({ + threadId, + input: "Stop this turn", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }); + const stoppedMessageId = (runtimeMock.state.promptCalls.at(-1) as { messageID: string }) + .messageID; + firstUserMessageEvent.resolve({ + id: "evt-first-user-before-polled-busy-turn", + type: "message.updated", + properties: { + sessionID: "http://127.0.0.1:9999/session", + info: { id: stoppedMessageId, role: "user" }, + }, + }); + yield* Effect.yieldNow; + yield* adapter.interruptTurn(threadId, stoppedTurn.turnId); + + runtimeMock.state.sessionStatusCalls = 0; + runtimeMock.state.sessionStatusImplementation = async () => { + if (runtimeMock.state.sessionStatusCalls === 1) { + busyStatusPolled.resolve(undefined); + return { + data: { "http://127.0.0.1:9999/session": { type: "busy" as const } }, + }; + } + return { data: {} }; + }; + const activeTurn = yield* adapter.sendTurn({ + threadId, + input: "Run without echo or busy events", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }); + yield* Effect.promise(() => busyStatusPolled.promise); + yield* Effect.yieldNow; + + assistantMessageEvent.resolve({ + id: "evt-assistant-after-polled-busy", + type: "message.updated", + properties: { + sessionID: "http://127.0.0.1:9999/session", + info: { id: "msg-assistant-after-polled-busy", role: "assistant" }, + }, + }); + assistantPartEvent.resolve({ + id: "evt-part-after-polled-busy", + type: "message.part.updated", + properties: { + sessionID: "http://127.0.0.1:9999/session", + part: { + id: "part-after-polled-busy", + sessionID: "http://127.0.0.1:9999/session", + messageID: "msg-assistant-after-polled-busy", + type: "text", + text: "Visible output", + time: { start: 1 }, + }, + time: 1, + }, + }); + idleEvent.resolve({ + id: "evt-idle-after-polled-busy", + type: "session.status", + properties: { + sessionID: "http://127.0.0.1:9999/session", + status: { type: "idle" }, + }, + }); + + const events = Array.from(yield* Fiber.join(eventsFiber).pipe(Effect.timeout("1 second"))); + NodeAssert.deepEqual( + events.map((event) => event.type), + ["content.delta", "turn.completed"], + ); + const delta = events[0]; + if (delta?.type === "content.delta") { + NodeAssert.equal(delta.payload.delta, "Visible output"); + } + NodeAssert.equal(events[1]?.turnId, activeTurn.turnId); + const sessions = yield* adapter.listSessions(); + const session = sessions.find((candidate) => candidate.threadId === threadId); + NodeAssert.equal(session?.status, "ready"); + NodeAssert.equal(session?.activeTurnId, undefined); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("ignores a stale admission status response after the next turn starts", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-stale-admission-status-after-stop"); + const idleEvent = promiseWithResolvers(); + const userMessageEvent = promiseWithResolvers(); + const staleStatusStarted = promiseWithResolvers(); + const staleStatusRelease = promiseWithResolvers(); + const staleStatusReturned = promiseWithResolvers(); + const activePromptStarted = promiseWithResolvers(); + const activePromptRelease = promiseWithResolvers(); + runtimeMock.state.autoPromptEcho = false; + runtimeMock.state.subscribedEvents = [idleEvent.promise, userMessageEvent.promise]; + runtimeMock.state.sessionStatusImplementation = async () => { + if (runtimeMock.state.sessionStatusCalls === 1) { + staleStatusStarted.resolve(undefined); + await staleStatusRelease.promise; + staleStatusReturned.resolve(undefined); + return { + data: { "http://127.0.0.1:9999/session": { type: "busy" as const } }, + }; + } + return { data: {} }; + }; + runtimeMock.state.promptAsyncImplementation = async () => { + if (runtimeMock.state.promptCalls.length === 2) { + activePromptStarted.resolve(undefined); + await activePromptRelease.promise; + } + }; + + const completedFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId && event.type === "turn.completed"), + Stream.runHead, + Effect.forkChild, + ); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + const stoppedTurn = yield* adapter.sendTurn({ + threadId, + input: "Stop while status is pending", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }); + yield* Effect.promise(() => staleStatusStarted.promise); + yield* adapter.interruptTurn(threadId, stoppedTurn.turnId); + + const activeTurnFiber = yield* adapter + .sendTurn({ + threadId, + input: "Start while the old status is pending", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }) + .pipe(Effect.forkChild); + yield* Effect.promise(() => activePromptStarted.promise); + const activeMessageId = (runtimeMock.state.promptCalls.at(-1) as { messageID: string }) + .messageID; + + staleStatusRelease.resolve(undefined); + yield* Effect.promise(() => staleStatusReturned.promise); + for (let index = 0; index < 2; index += 1) { + yield* Effect.yieldNow; + } + idleEvent.resolve({ + id: "evt-idle-after-stale-admission-status", + type: "session.status", + properties: { + sessionID: "http://127.0.0.1:9999/session", + status: { type: "idle" }, + }, + }); + for (let index = 0; index < 4; index += 1) { + yield* Effect.yieldNow; + } + NodeAssert.equal(activeTurnFiber.pollUnsafe(), undefined); + NodeAssert.equal(completedFiber.pollUnsafe(), undefined); + const sessionsBeforeAcceptance = yield* adapter.listSessions(); + const sessionBeforeAcceptance = sessionsBeforeAcceptance.find( + (candidate) => candidate.threadId === threadId, + ); + NodeAssert.equal(sessionBeforeAcceptance?.status, "running"); + NodeAssert.notEqual(sessionBeforeAcceptance?.activeTurnId, stoppedTurn.turnId); + + userMessageEvent.resolve({ + id: "evt-user-after-stale-admission-status", + type: "message.updated", + properties: { + sessionID: "http://127.0.0.1:9999/session", + info: { id: activeMessageId, role: "user" }, + }, + }); + yield* Effect.yieldNow; + activePromptRelease.resolve(undefined); + const activeTurn = yield* Fiber.join(activeTurnFiber); + yield* advanceTestClock(250); + + const completed = Option.getOrUndefined( + yield* Fiber.join(completedFiber).pipe(Effect.timeout("1 second")), + ); + NodeAssert.equal(completed?.turnId, activeTurn.turnId); + const sessions = yield* adapter.listSessions(); + const session = sessions.find((candidate) => candidate.threadId === threadId); + NodeAssert.equal(session?.status, "ready"); + NodeAssert.equal(session?.activeTurnId, undefined); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("reconciles a sole idle when the matching prompt echo arrives later", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-idle-before-delayed-echo"); + const idleEvent = promiseWithResolvers(); + const userMessageEvent = promiseWithResolvers(); + runtimeMock.state.autoPromptEcho = false; + runtimeMock.state.subscribedEvents = [idleEvent.promise, userMessageEvent.promise]; + runtimeMock.state.sessionStatusImplementation = async () => ({ data: {} }); + + const completedFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId && event.type === "turn.completed"), + Stream.runHead, + Effect.forkChild, + ); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + const turn = yield* adapter.sendTurn({ + threadId, + input: "Finish before the echo arrives", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }); + const messageId = (runtimeMock.state.promptCalls[0] as { messageID?: string }).messageID; + idleEvent.resolve({ + id: "evt-idle-before-delayed-echo", + type: "session.status", + properties: { + sessionID: "http://127.0.0.1:9999/session", + status: { type: "idle" }, + }, + }); + userMessageEvent.resolve({ + id: "evt-delayed-matching-echo", + type: "message.updated", + properties: { + sessionID: "http://127.0.0.1:9999/session", + info: { id: messageId, role: "user" }, + }, + }); + yield* advanceTestClock(250); + + const completed = Option.getOrUndefined( + yield* Fiber.join(completedFiber).pipe(Effect.timeout("1 second")), + ); + NodeAssert.equal(completed?.turnId, turn.turnId); + const sessions = yield* adapter.listSessions(); + const session = sessions.find((candidate) => candidate.threadId === threadId); + NodeAssert.equal(session?.status, "ready"); + NodeAssert.equal(session?.activeTurnId, undefined); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("reconciles the only idle after a stopped turn when the prompt echo is missing", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-idle-only-without-echo-after-stop"); + const idleEvent = promiseWithResolvers(); + runtimeMock.state.autoPromptEcho = false; + runtimeMock.state.subscribedEvents = [idleEvent.promise]; + runtimeMock.state.sessionStatusImplementation = async () => ({ data: {} }); + runtimeMock.state.promptAsyncImplementation = async () => { + const prompt = runtimeMock.state.promptCalls.at(-1) as { messageID?: string } | undefined; + if (prompt?.messageID) { + runtimeMock.state.messages.push({ + info: { id: prompt.messageID, role: "user" }, + parts: [], + }); + } + }; + + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + const stoppedTurn = yield* adapter.sendTurn({ + threadId, + input: "Stop this turn", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }); + yield* adapter.interruptTurn(threadId, stoppedTurn.turnId); + const activeTurn = yield* adapter.sendTurn({ + threadId, + input: "Run after the stop", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }); + const activeMessageId = ( + runtimeMock.state.promptCalls.at(-1) as { messageID?: string } | undefined + )?.messageID; + idleEvent.resolve({ + id: "evt-only-idle-without-echo-after-stop", + type: "session.status", + properties: { + sessionID: "http://127.0.0.1:9999/session", + status: { type: "idle" }, + }, + }); + yield* advanceTestClock(1_000); + + NodeAssert.equal( + runtimeMock.state.messageCalls.some((call) => call.messageID === activeMessageId), + true, + ); + NodeAssert.equal(runtimeMock.state.sessionStatusCalls > 0, true); + const sessions = yield* adapter.listSessions(); + const session = sessions.find((candidate) => candidate.threadId === threadId); + NodeAssert.equal(session?.status, "ready"); + NodeAssert.equal(session?.activeTurnId, undefined); + NodeAssert.notEqual(activeTurn.turnId, stoppedTurn.turnId); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("reconciles a sole idle after a stop when the exact prompt echo arrives", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-idle-before-exact-echo-after-stop"); + const idleEvent = promiseWithResolvers(); + const userMessageEvent = promiseWithResolvers(); + runtimeMock.state.autoPromptEcho = false; + runtimeMock.state.subscribedEvents = [idleEvent.promise, userMessageEvent.promise]; + runtimeMock.state.sessionStatusImplementation = async () => ({ data: {} }); + + const completedFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId && event.type === "turn.completed"), + Stream.runHead, + Effect.forkChild, + ); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + const stoppedTurn = yield* adapter.sendTurn({ + threadId, + input: "Stop this turn", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }); + yield* adapter.interruptTurn(threadId, stoppedTurn.turnId); + const activeTurn = yield* adapter.sendTurn({ + threadId, + input: "Run after the stop", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }); + const activeMessageId = ( + runtimeMock.state.promptCalls.at(-1) as { messageID?: string } | undefined + )?.messageID; + idleEvent.resolve({ + id: "evt-only-idle-before-exact-echo-after-stop", + type: "session.status", + properties: { + sessionID: "http://127.0.0.1:9999/session", + status: { type: "idle" }, + }, + }); + yield* Effect.yieldNow; + const sessionsBeforeEcho = yield* adapter.listSessions(); + const sessionBeforeEcho = sessionsBeforeEcho.find( + (candidate) => candidate.threadId === threadId, + ); + NodeAssert.equal(sessionBeforeEcho?.status, "running"); + NodeAssert.equal(sessionBeforeEcho?.activeTurnId, activeTurn.turnId); + + userMessageEvent.resolve({ + id: "evt-exact-prompt-echo-after-stop", + type: "message.updated", + properties: { + sessionID: "http://127.0.0.1:9999/session", + info: { id: activeMessageId, role: "user" }, + }, + }); + yield* advanceTestClock(250); + + const completed = Option.getOrUndefined( + yield* Fiber.join(completedFiber).pipe(Effect.timeout("1 second")), + ); + NodeAssert.equal(completed?.turnId, activeTurn.turnId); + const sessions = yield* adapter.listSessions(); + const session = sessions.find((candidate) => candidate.threadId === threadId); + NodeAssert.equal(session?.status, "ready"); + NodeAssert.equal(session?.activeTurnId, undefined); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("recovers an idle before the exact prompt echo while acceptance is held", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-idle-and-echo-before-acceptance-after-stop"); + const idleEvent = promiseWithResolvers(); + const userMessageEvent = promiseWithResolvers(); + const activePromptStarted = promiseWithResolvers(); + const activePromptRelease = promiseWithResolvers(); + runtimeMock.state.autoPromptEcho = false; + runtimeMock.state.subscribedEvents = [idleEvent.promise, userMessageEvent.promise]; + runtimeMock.state.sessionStatusImplementation = async () => ({ data: {} }); + runtimeMock.state.promptAsyncImplementation = async () => { + if (runtimeMock.state.promptCalls.length === 2) { + activePromptStarted.resolve(undefined); + await activePromptRelease.promise; + } + }; + + const completedFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId && event.type === "turn.completed"), + Stream.runHead, + Effect.forkChild, + ); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + const stoppedTurn = yield* adapter.sendTurn({ + threadId, + input: "Stop this turn", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }); + yield* adapter.interruptTurn(threadId, stoppedTurn.turnId); + + const activeTurnFiber = yield* adapter + .sendTurn({ + threadId, + input: "Run after the stop", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }) + .pipe(Effect.forkChild); + yield* Effect.promise(() => activePromptStarted.promise); + const activeMessageId = (runtimeMock.state.promptCalls.at(-1) as { messageID: string }) + .messageID; + idleEvent.resolve({ + id: "evt-idle-before-held-prompt-acceptance", + type: "session.status", + properties: { + sessionID: "http://127.0.0.1:9999/session", + status: { type: "idle" }, + }, + }); + yield* Effect.yieldNow; + userMessageEvent.resolve({ + id: "evt-exact-echo-before-held-prompt-acceptance", + type: "message.updated", + properties: { + sessionID: "http://127.0.0.1:9999/session", + info: { id: activeMessageId, role: "user" }, + }, + }); + yield* Effect.yieldNow; + NodeAssert.equal(activeTurnFiber.pollUnsafe(), undefined); + + activePromptRelease.resolve(undefined); + const activeTurn = yield* Fiber.join(activeTurnFiber); + yield* advanceTestClock(250); + + const completed = Option.getOrUndefined( + yield* Fiber.join(completedFiber).pipe(Effect.timeout("1 second")), + ); + NodeAssert.equal(completed?.turnId, activeTurn.turnId); + const sessions = yield* adapter.listSessions(); + const session = sessions.find((candidate) => candidate.threadId === threadId); + NodeAssert.equal(session?.status, "ready"); + NodeAssert.equal(session?.activeTurnId, undefined); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("restores idle reconciliation after a steer prompt fails", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-failed-steer-idle"); + const busyEvent = promiseWithResolvers(); + const idleEvent = promiseWithResolvers(); + const firstStatusStarted = promiseWithResolvers(); + const firstStatusRelease = promiseWithResolvers(); + const steerStarted = promiseWithResolvers(); + const steerRelease = promiseWithResolvers(); + runtimeMock.state.subscribedEvents = [busyEvent.promise, idleEvent.promise]; + runtimeMock.state.sessionStatusImplementation = async () => { + if (runtimeMock.state.sessionStatusCalls === 1) { + firstStatusStarted.resolve(undefined); + await firstStatusRelease.promise; + } + return { data: {} }; + }; + runtimeMock.state.promptAsyncImplementation = async () => { + if (runtimeMock.state.promptCalls.length === 3) { + steerStarted.resolve(undefined); + await steerRelease.promise; + throw new Error("steer failed"); + } + }; + + const completedFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId && event.type === "turn.completed"), + Stream.runHead, + Effect.forkChild, + ); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + const stoppedTurn = yield* adapter.sendTurn({ + threadId, + input: "Stop this turn", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }); + yield* adapter.interruptTurn(threadId, stoppedTurn.turnId); + const activeTurn = yield* adapter.sendTurn({ + threadId, + input: "Start the next turn", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }); + busyEvent.resolve({ + id: "evt-failed-steer-busy", + type: "session.status", + properties: { + sessionID: "http://127.0.0.1:9999/session", + status: { type: "busy" }, + }, + }); + idleEvent.resolve({ + id: "evt-failed-steer-idle", + type: "session.status", + properties: { + sessionID: "http://127.0.0.1:9999/session", + status: { type: "idle" }, + }, + }); + yield* Effect.promise(() => firstStatusStarted.promise); + const steerFiber = yield* Effect.exit( + adapter.sendTurn({ + threadId, + input: "This steer fails", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }), + ).pipe(Effect.forkChild); + yield* Effect.promise(() => steerStarted.promise); + firstStatusRelease.resolve(undefined); + steerRelease.resolve(undefined); + const steerExit = yield* Fiber.join(steerFiber); + NodeAssert.equal(Exit.isFailure(steerExit), true); + + const completed = Option.getOrUndefined( + yield* Fiber.join(completedFiber).pipe(Effect.timeout("1 second")), + ); + NodeAssert.equal(completed?.turnId, activeTurn.turnId); + NodeAssert.equal(runtimeMock.state.sessionStatusCalls, 2); + }), + ); + + it.effect("accepts the only idle event after a steer fails before creating its message", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-failed-steer-admission-idle"); + const idleEvent = promiseWithResolvers(); + const steerStarted = promiseWithResolvers(); + const steerRelease = promiseWithResolvers(); + runtimeMock.state.subscribedEvents = [idleEvent.promise]; + runtimeMock.state.sessionStatusImplementation = async () => ({ data: {} }); + runtimeMock.state.promptAsyncImplementation = async () => { + if (runtimeMock.state.promptCalls.length === 2) { + steerStarted.resolve(undefined); + await steerRelease.promise; + throw new Error("steer failed before message creation"); + } + }; + + const completedFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId && event.type === "turn.completed"), + Stream.runHead, + Effect.forkChild, + ); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + const activeTurn = yield* adapter.sendTurn({ + threadId, + input: "Start work", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }); + const steerFiber = yield* Effect.exit( + adapter.sendTurn({ + threadId, + input: "This steer fails", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }), + ).pipe(Effect.forkChild); + yield* Effect.promise(() => steerStarted.promise); + idleEvent.resolve({ + id: "evt-idle-during-failed-admission", + type: "session.status", + properties: { + sessionID: "http://127.0.0.1:9999/session", + status: { type: "idle" }, + }, + }); + steerRelease.resolve(undefined); + NodeAssert.equal(Exit.isFailure(yield* Fiber.join(steerFiber)), true); + + const completed = Option.getOrUndefined( + yield* Fiber.join(completedFiber).pipe(Effect.timeout("1 second")), + ); + NodeAssert.equal(completed?.turnId, activeTurn.turnId); + }), + ); + + it.effect("routes child-session approval requests and replies through the parent thread", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-child-approval"); + const permissionReply = promiseWithResolvers(); + runtimeMock.state.subscribedEvents = [ + { + id: "evt-child-created", + type: "session.created", + properties: { + sessionID: "ses_child", + info: { + id: "ses_child", + parentID: "http://127.0.0.1:9999/session", + title: "Child session", + }, + }, + }, + { + id: "evt-child-permission", + type: "permission.asked", + properties: { + id: "per_child", + sessionID: "ses_child", + permission: "external_directory", + patterns: ["/tmp/external/*"], + metadata: { source: "child" }, + always: ["/tmp/external/*"], + }, + }, + permissionReply.promise, + ]; + + const openedEventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId), + Stream.take(3), + Stream.runCollect, + Effect.forkChild, + ); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "approval-required", + }); + + const openedEvents = Array.from( + yield* Fiber.join(openedEventsFiber).pipe(Effect.timeout("1 second")), + ); + const opened = openedEvents.find((event) => event.type === "request.opened"); + NodeAssert.ok(opened); + NodeAssert.equal(opened.requestId, "per_child"); + NodeAssert.equal( + opened.raw?.source === "opencode.sdk.event" && + typeof opened.raw.payload === "object" && + opened.raw.payload !== null && + "properties" in opened.raw.payload + ? (opened.raw.payload.properties as { sessionID?: string }).sessionID + : undefined, + "ses_child", + ); + + yield* adapter.respondToRequest( + threadId, + ApprovalRequestId.make("per_child"), + "acceptForSession", + ); + NodeAssert.deepEqual(runtimeMock.state.permissionReplyCalls, [ + { requestID: "per_child", reply: "always" }, + ]); + + const resolvedEventFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId), + Stream.take(1), + Stream.runHead, + Effect.forkChild, + ); + permissionReply.resolve({ + id: "evt-child-permission-replied", + type: "permission.replied", + properties: { + sessionID: "ses_child", + requestID: "per_child", + reply: "always", + }, + }); + const resolved = yield* Fiber.join(resolvedEventFiber).pipe(Effect.timeout("1 second")); + NodeAssert.equal(Option.getOrUndefined(resolved)?.type, "request.resolved"); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("routes child-session questions and replies through the parent thread", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-child-question"); + const questionReply = promiseWithResolvers(); + runtimeMock.state.subscribedEvents = [ + { + id: "evt-child-created", + type: "session.created", + properties: { + sessionID: "ses_child_question", + info: { + id: "ses_child_question", + parentID: "http://127.0.0.1:9999/session", + title: "Child session", + }, + }, + }, + { + id: "evt-child-question", + type: "question.asked", + properties: { + id: "que_child", + sessionID: "ses_child_question", + questions: [ + { + header: "Scope", + question: "Which scope should OpenCode use?", + options: [{ label: "Workspace", description: "Use this workspace." }], + }, + ], + }, + }, + questionReply.promise, + ]; + + const requestedEventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId), + Stream.take(3), + Stream.runCollect, + Effect.forkChild, + ); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "approval-required", + }); + + const requestedEvents = Array.from( + yield* Fiber.join(requestedEventsFiber).pipe(Effect.timeout("1 second")), + ); + const requested = requestedEvents.find((event) => event.type === "user-input.requested"); + NodeAssert.ok(requested); + NodeAssert.equal(requested.requestId, "que_child"); + + yield* adapter.respondToUserInput(threadId, ApprovalRequestId.make("que_child"), { + Scope: "Workspace", + }); + NodeAssert.deepEqual(runtimeMock.state.questionReplyCalls, [ + { requestID: "que_child", answers: [["Workspace"]] }, + ]); + + const resolvedEventFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId), + Stream.take(1), + Stream.runHead, + Effect.forkChild, + ); + questionReply.resolve({ + id: "evt-child-question-replied", + type: "question.replied", + properties: { + sessionID: "ses_child_question", + requestID: "que_child", + answers: [["Workspace"]], + }, + }); + const resolved = yield* Fiber.join(resolvedEventFiber).pipe(Effect.timeout("1 second")); + NodeAssert.equal(Option.getOrUndefined(resolved)?.type, "user-input.resolved"); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("recovers pending requests from existing nested child sessions on resume", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-resume-child-requests"); + runtimeMock.state.sessionParentById.set("ses_child", "ses_parent"); + runtimeMock.state.sessionParentById.set("ses_nested", "ses_child"); + runtimeMock.state.pendingPermissions = [permissionRequest("per_existing", "ses_nested")]; + runtimeMock.state.pendingQuestions = [questionRequest("que_existing", "ses_child")]; + + const requestsFiber = yield* adapter.streamEvents.pipe( + Stream.filter( + (event) => + event.threadId === threadId && + (event.type === "request.opened" || event.type === "user-input.requested"), + ), + Stream.take(2), + Stream.runCollect, + Effect.forkChild, + ); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "approval-required", + resumeCursor: { schemaVersion: 1, sessionId: "ses_parent" }, + }); + + const requests = Array.from( + yield* Fiber.join(requestsFiber).pipe(Effect.timeout("1 second")), + ); + NodeAssert.deepEqual(requests.map((event) => [event.type, event.requestId]).sort(), [ + ["request.opened", "per_existing"], + ["user-input.requested", "que_existing"], + ]); + yield* adapter.respondToRequest(threadId, ApprovalRequestId.make("per_existing"), "accept"); + yield* adapter.respondToUserInput(threadId, ApprovalRequestId.make("que_existing"), { + Scope: "Workspace", + }); + NodeAssert.deepEqual(runtimeMock.state.permissionReplyCalls, [ + { requestID: "per_existing", reply: "once" }, + ]); + NodeAssert.deepEqual(runtimeMock.state.questionReplyCalls, [ + { requestID: "que_existing", answers: [["Workspace"]] }, + ]); + }), + ); + + it.effect("retries ancestry for one live child request after a transient failure", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-child-request-ancestry-retry"); + const parentId = "http://127.0.0.1:9999/session"; + const ancestryAttempted = promiseWithResolvers(); + runtimeMock.state.sessionParentById.set("ses_existing_child", parentId); + runtimeMock.state.transientErrorSessionIds.add("ses_existing_child"); + runtimeMock.state.sessionGetObserved = (sessionID) => { + if (sessionID === "ses_existing_child") { + ancestryAttempted.resolve(undefined); + } + }; + runtimeMock.state.subscribedEvents = [ + { + id: "evt-existing-child-permission", + type: "permission.asked", + properties: permissionRequest("per_retry", "ses_existing_child"), + }, + ]; + + const eventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter( + (event) => + event.threadId === threadId && + (event.type === "runtime.warning" || event.type === "request.opened"), + ), + Stream.take(2), + Stream.runCollect, + Effect.forkChild, + ); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "approval-required", + }); + yield* Effect.promise(() => ancestryAttempted.promise); + runtimeMock.state.transientErrorSessionIds.delete("ses_existing_child"); + yield* advanceTestClock(250); + + const events = Array.from(yield* Fiber.join(eventsFiber).pipe(Effect.timeout("1 second"))); + NodeAssert.deepEqual( + events.map((event) => event.type), + ["runtime.warning", "request.opened"], + ); + yield* adapter.respondToRequest(threadId, ApprovalRequestId.make("per_retry"), "accept"); + }), + ); + + it.effect("does not resurrect a recovered child request after its live reply", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-stale-child-request-recovery"); + const listStarted = promiseWithResolvers(); + const listRelease = promiseWithResolvers(); + const stale = permissionRequest("per_stale", "ses_existing_child"); + runtimeMock.state.sessionParentById.set("ses_existing_child", "ses_parent"); + runtimeMock.state.permissionListImplementation = async () => { + listStarted.resolve(undefined); + await listRelease.promise; + return [stale]; + }; + runtimeMock.state.subscribedEvents = [ + { + id: "evt-stale-child-replied", + type: "permission.replied", + properties: { + sessionID: "ses_existing_child", + requestID: stale.id, + reply: "once", + }, + }, + ]; + + const resolvedFiber = yield* adapter.streamEvents.pipe( + Stream.filter( + (event) => + event.threadId === threadId && + (event.type === "request.opened" || event.type === "request.resolved"), + ), + Stream.runHead, + Effect.forkChild, + ); + + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "approval-required", + resumeCursor: { schemaVersion: 1, sessionId: "ses_parent" }, + }); + yield* Effect.promise(() => listStarted.promise); + const resolved = Option.getOrUndefined( + yield* Fiber.join(resolvedFiber).pipe(Effect.timeout("1 second")), + ); + NodeAssert.equal(resolved?.type, "request.resolved"); + listRelease.resolve(undefined); + yield* Effect.yieldNow; + + const response = yield* Effect.exit( + adapter.respondToRequest(threadId, ApprovalRequestId.make(stale.id), "accept"), + ); + NodeAssert.equal(Exit.isFailure(response), true); + }), + ); + + it.effect("lets a child reply supersede an ask while ancestry lookup is retrying", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-child-terminal-during-ancestry"); + const ancestryAttempted = promiseWithResolvers(); + const childId = "ses_terminal_child"; + const request = permissionRequest("per_terminal", childId); + runtimeMock.state.sessionParentById.set(childId, "http://127.0.0.1:9999/session"); + runtimeMock.state.transientErrorSessionIds.add(childId); + runtimeMock.state.sessionGetObserved = (sessionID) => { + if (sessionID === childId) { + ancestryAttempted.resolve(undefined); + } + }; + runtimeMock.state.subscribedEvents = [ + { id: "evt-terminal-ask", type: "permission.asked", properties: request }, + { + id: "evt-terminal-reply", + type: "permission.replied", + properties: { sessionID: childId, requestID: request.id, reply: "once" }, + }, + ]; + + const terminalFiber = yield* adapter.streamEvents.pipe( + Stream.filter( + (event) => + event.threadId === threadId && + (event.type === "request.opened" || event.type === "request.resolved"), + ), + Stream.runHead, + Effect.forkChild, + ); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "approval-required", + }); + yield* Effect.promise(() => ancestryAttempted.promise); + runtimeMock.state.transientErrorSessionIds.delete(childId); + yield* advanceTestClock(250); + + const terminal = Option.getOrUndefined( + yield* Fiber.join(terminalFiber).pipe(Effect.timeout("1 second")), + ); + NodeAssert.equal(terminal?.type, "request.resolved"); + const response = yield* Effect.exit( + adapter.respondToRequest(threadId, ApprovalRequestId.make(request.id), "accept"), + ); + NodeAssert.equal(Exit.isFailure(response), true); + }), + ); + + it.effect("caps terminal ancestry retries after a request finishes", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-terminal-ancestry-retry-cap"); + const childId = "ses_terminal_retry_cap_child"; + const request = permissionRequest("per_terminal_retry_cap", childId); + const terminalEvent = promiseWithResolvers(); + const askedAttempted = promiseWithResolvers(); + const terminalAttempted = promiseWithResolvers(); + let terminalReleased = false; + runtimeMock.state.transientErrorSessionIds.add(childId); + runtimeMock.state.sessionGetObserved = (sessionID) => { + if (sessionID !== childId) { + return; + } + if (terminalReleased) { + terminalAttempted.resolve(undefined); + } else { + askedAttempted.resolve(undefined); + } + }; + runtimeMock.state.subscribedEvents = [ + { id: "evt-terminal-cap-ask", type: "permission.asked", properties: request }, + terminalEvent.promise, + ]; + + const unexpectedRequestFiber = yield* adapter.streamEvents.pipe( + Stream.filter( + (event) => + event.threadId === threadId && + (event.type === "request.opened" || event.type === "request.resolved"), + ), + Stream.runHead, + Effect.forkChild, + ); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "approval-required", + }); + yield* Effect.promise(() => askedAttempted.promise); + const askedAttempts = runtimeMock.state.sessionGetIds.filter( + (sessionID) => sessionID === childId, + ).length; + + terminalReleased = true; + terminalEvent.resolve({ + id: "evt-terminal-cap-reply", + type: "permission.replied", + properties: { sessionID: childId, requestID: request.id, reply: "once" }, + }); + yield* Effect.promise(() => terminalAttempted.promise); + yield* advanceTestClock(10_000); + const callsAfterCap = runtimeMock.state.sessionGetIds.filter( + (sessionID) => sessionID === childId, + ).length; + NodeAssert.equal(callsAfterCap - askedAttempts, 5); + + yield* advanceTestClock(30_000); + NodeAssert.equal( + runtimeMock.state.sessionGetIds.filter((sessionID) => sessionID === childId).length, + callsAfterCap, + ); + NodeAssert.equal(unexpectedRequestFiber.pollUnsafe(), undefined); + yield* Fiber.interrupt(unexpectedRequestFiber); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("reruns recovery when the event stream connects during the startup snapshot", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-connected-recovery-rerun"); + const firstListStarted = promiseWithResolvers(); + const firstListRelease = promiseWithResolvers(); + const pending = permissionRequest("per_connected", "ses_existing_child"); + runtimeMock.state.sessionParentById.set("ses_existing_child", "ses_parent"); + runtimeMock.state.permissionListImplementation = async () => { + if (runtimeMock.state.permissionListCalls === 1) { + firstListStarted.resolve(undefined); + await firstListRelease.promise; + return []; + } + return [pending]; + }; + runtimeMock.state.subscribedEvents = [ + { id: "evt-connected", type: "server.connected", properties: {} }, + ]; + + const openedFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId && event.type === "request.opened"), + Stream.runHead, + Effect.forkChild, + ); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "approval-required", + resumeCursor: { schemaVersion: 1, sessionId: "ses_parent" }, + }); + yield* Effect.promise(() => firstListStarted.promise); + firstListRelease.resolve(undefined); + + const opened = Option.getOrUndefined( + yield* Fiber.join(openedFiber).pipe(Effect.timeout("1 second")), + ); + NodeAssert.equal(opened?.requestId, pending.id); + NodeAssert.equal(runtimeMock.state.permissionListCalls, 2); + }), + ); + + it.effect("stops the full OpenCode child tree before it completes the interrupt", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-interrupt-child-tree"); + const parentAbortEvent = promiseWithResolvers(); + const markerEvent = promiseWithResolvers(); + const parentAbortStarted = promiseWithResolvers(); + const parentAbortRelease = promiseWithResolvers(); + const childAbortStarted = promiseWithResolvers(); + const childAbortRelease = promiseWithResolvers(); + const rootSessionId = "http://127.0.0.1:9999/session"; + runtimeMock.state.subscribedEvents = [parentAbortEvent.promise, markerEvent.promise]; + runtimeMock.state.sessionChildrenById.set(rootSessionId, [ + { id: "ses_child_a" }, + { id: "ses_child_b" }, + ]); + runtimeMock.state.sessionChildrenById.set("ses_child_a", [{ id: "ses_grandchild" }]); + runtimeMock.state.sessionChildrenById.set("ses_unrelated", [{ id: "ses_unrelated_child" }]); + runtimeMock.state.abortImplementation = async (sessionID) => { + if (sessionID === rootSessionId) { + parentAbortStarted.resolve(undefined); + await parentAbortRelease.promise; + } + if (sessionID === "ses_child_a") { + childAbortStarted.resolve(undefined); + await childAbortRelease.promise; + } + }; + + const markerFiber = yield* adapter.streamEvents.pipe( + Stream.filter( + (event) => event.threadId === threadId && event.type === "thread.metadata.updated", + ), + Stream.runHead, + Effect.forkChild, + ); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + const turn = yield* adapter.sendTurn({ + threadId, + input: "Run child agents", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }); + + const interruptFiber = yield* adapter + .interruptTurn(threadId, turn.turnId) + .pipe(Effect.result, Effect.forkChild); + yield* Effect.promise(() => parentAbortStarted.promise); + runtimeMock.state.sessionChildrenById.get(rootSessionId)?.push({ id: "ses_late_child" }); + parentAbortEvent.resolve({ + id: "evt-parent-aborted", + type: "session.error", + properties: { + sessionID: rootSessionId, + error: { name: "MessageAbortedError", data: { message: "Aborted" } }, + }, + }); + markerEvent.resolve({ + id: "evt-after-parent-abort", + type: "session.updated", + properties: { info: { id: rootSessionId, title: "Parent abort received" } }, + }); + yield* Fiber.join(markerFiber); + + NodeAssert.equal(interruptFiber.pollUnsafe(), undefined); + yield* Effect.promise(() => childAbortStarted.promise); + NodeAssert.equal(interruptFiber.pollUnsafe(), undefined); + NodeAssert.equal(runtimeMock.state.abortCalls.includes("ses_unrelated"), false); + NodeAssert.equal(runtimeMock.state.abortCalls.includes("ses_unrelated_child"), false); + const sessionsDuringCleanup = yield* adapter.listSessions(); + const sessionDuringCleanup = sessionsDuringCleanup.find( + (candidate) => candidate.threadId === threadId, + ); + NodeAssert.equal(sessionDuringCleanup?.status, "running"); + NodeAssert.equal(sessionDuringCleanup?.activeTurnId, turn.turnId); + const nextTurnFiber = yield* adapter + .sendTurn({ + threadId, + input: "Start after every child stops", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }) + .pipe(Effect.forkChild); + yield* Effect.yieldNow; + NodeAssert.equal(runtimeMock.state.promptCalls.length, 1); + + childAbortRelease.resolve(undefined); + parentAbortRelease.resolve(undefined); + const result = yield* Fiber.join(interruptFiber); + const nextTurn = yield* Fiber.join(nextTurnFiber); + NodeAssert.equal(result._tag, "Success"); + NodeAssert.notEqual(nextTurn.turnId, turn.turnId); + NodeAssert.equal(runtimeMock.state.promptCalls.length, 2); + NodeAssert.equal(runtimeMock.state.abortCalls[0], rootSessionId); + NodeAssert.deepEqual( + new Set(runtimeMock.state.abortCalls.slice(1)), + new Set(["ses_child_a", "ses_child_b", "ses_grandchild", "ses_late_child"]), + ); + NodeAssert.deepEqual( + new Set(runtimeMock.state.sessionChildrenCalls), + new Set([rootSessionId, "ses_child_a", "ses_child_b", "ses_grandchild", "ses_late_child"]), + ); + const sessions = yield* adapter.listSessions(); + const session = sessions.find((candidate) => candidate.threadId === threadId); + NodeAssert.equal(session?.status, "running"); + NodeAssert.equal(session?.activeTurnId, nextTurn.turnId); + + runtimeMock.state.abortImplementation = null; + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("limits SDK requests across the full OpenCode child tree", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-interrupt-child-request-limit"); + const rootSessionId = "http://127.0.0.1:9999/session"; + const requestRelease = promiseWithResolvers(); + const limitReached = promiseWithResolvers(); + let inFlight = 0; + let maxInFlight = 0; + const holdRequest = async (result: T): Promise => { + inFlight += 1; + maxInFlight = Math.max(maxInFlight, inFlight); + if (inFlight === 8) { + limitReached.resolve(undefined); + } + await requestRelease.promise; + inFlight -= 1; + return result; + }; + + const children = Array.from({ length: 8 }, (_, index) => ({ id: `ses_child_${index}` })); + runtimeMock.state.sessionChildrenById.set(rootSessionId, children); + for (const child of children.slice(1)) { + runtimeMock.state.sessionChildrenById.set( + child.id, + Array.from({ length: 8 }, (_, index) => ({ id: `${child.id}_nested_${index}` })), + ); + } + runtimeMock.state.abortImplementation = async (sessionID) => { + if (sessionID.includes("_nested_")) { + await holdRequest(undefined); + } + }; + runtimeMock.state.sessionChildrenImplementation = async (sessionID) => { + if (sessionID === "ses_child_0") { + return await holdRequest([]); + } + return runtimeMock.state.sessionChildrenById.get(sessionID) ?? []; + }; + + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + const turn = yield* adapter.sendTurn({ + threadId, + input: "Run a nested child tree", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }); + + const interruptFiber = yield* adapter + .interruptTurn(threadId, turn.turnId) + .pipe(Effect.forkChild); + yield* Effect.promise(() => limitReached.promise); + yield* Effect.yieldNow; + + NodeAssert.equal(inFlight, 8); + NodeAssert.equal(maxInFlight, 8); + + requestRelease.resolve(undefined); + yield* Fiber.join(interruptFiber); + + runtimeMock.state.abortImplementation = null; + runtimeMock.state.sessionChildrenImplementation = null; + runtimeMock.state.sessionChildrenById.clear(); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("attempts every child abort and fails the interrupt when one child abort fails", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-interrupt-child-failure"); + const rootSessionId = "http://127.0.0.1:9999/session"; + const failingChildStarted = promiseWithResolvers(); + const failingChildRelease = promiseWithResolvers(); + const siblingAbortStarted = promiseWithResolvers(); + runtimeMock.state.sessionChildrenById.set(rootSessionId, [ + { id: "ses_failing_child" }, + { id: "ses_surviving_sibling" }, + ]); + runtimeMock.state.abortImplementation = async (sessionID) => { + if (sessionID === "ses_failing_child") { + failingChildStarted.resolve(undefined); + await failingChildRelease.promise; + throw new Error("child abort failed"); + } + if (sessionID === "ses_surviving_sibling") { + siblingAbortStarted.resolve(undefined); + } + }; + + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + const turn = yield* adapter.sendTurn({ + threadId, + input: "Run child agents", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }); + + const interruptFiber = yield* adapter + .interruptTurn(threadId, turn.turnId) + .pipe(Effect.result, Effect.forkChild); + yield* Effect.promise(() => failingChildStarted.promise); + yield* Effect.promise(() => siblingAbortStarted.promise); + NodeAssert.equal(interruptFiber.pollUnsafe(), undefined); + failingChildRelease.resolve(undefined); + const result = yield* Fiber.join(interruptFiber); + + NodeAssert.equal(result._tag, "Failure"); + if (result._tag === "Failure") { + NodeAssert.equal(result.failure._tag, "ProviderAdapterRequestError"); + NodeAssert.equal(result.failure.detail, "child abort failed"); + } + NodeAssert.equal(runtimeMock.state.abortCalls.includes("ses_failing_child"), true); + NodeAssert.equal(runtimeMock.state.abortCalls.includes("ses_surviving_sibling"), true); + const sessions = yield* adapter.listSessions(); + const session = sessions.find((candidate) => candidate.threadId === threadId); + NodeAssert.equal(session?.status, "running"); + NodeAssert.equal(session?.activeTurnId, turn.turnId); + + runtimeMock.state.abortImplementation = null; + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("keeps an idle event from completing a turn while its abort request is pending", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-interrupt-idle-race"); + const idleEvent = promiseWithResolvers(); + const abortStarted = promiseWithResolvers(); + const abortRelease = promiseWithResolvers(); + runtimeMock.state.subscribedEvents = [idleEvent.promise]; + runtimeMock.state.abortImplementation = async () => { + abortStarted.resolve(undefined); + await abortRelease.promise; + }; + + const eventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId), + Stream.take(4), + Stream.runCollect, + Effect.forkChild, + ); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + const turn = yield* adapter.sendTurn({ + threadId, + input: "Keep working", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }); + + const interruptFiber = yield* adapter + .interruptTurn(threadId, turn.turnId) + .pipe(Effect.forkChild); + yield* Effect.promise(() => abortStarted.promise); + idleEvent.resolve({ + id: "evt-idle-after-stop", + type: "session.status", + properties: { + sessionID: "http://127.0.0.1:9999/session", + status: { type: "idle" }, + }, + }); + yield* Effect.yieldNow; + abortRelease.resolve(undefined); + yield* Fiber.join(interruptFiber); + + const events = Array.from(yield* Fiber.join(eventsFiber).pipe(Effect.timeout("1 second"))); + NodeAssert.deepEqual( + events + .filter((event) => event.type === "turn.completed" || event.type === "turn.aborted") + .map((event) => event.type), + ["turn.aborted"], + ); + const sessions = yield* adapter.listSessions(); + const session = sessions.find((candidate) => candidate.threadId === threadId); + NodeAssert.equal(session?.status, "ready"); + NodeAssert.equal(session?.activeTurnId, undefined); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("ignores late busy and idle status after an interrupted turn", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-late-status-after-interrupt"); + const lateBusy = promiseWithResolvers(); + const lateIdle = promiseWithResolvers(); + runtimeMock.state.subscribedEvents = [lateBusy.promise, lateIdle.promise]; + + const eventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId), + Stream.take(5), + Stream.runCollect, + Effect.forkChild, + ); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + const turn = yield* adapter.sendTurn({ + threadId, + input: "Stop this turn", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }); + yield* adapter.interruptTurn(threadId, turn.turnId); + + lateBusy.resolve({ + id: "evt-late-busy-after-interrupt", + type: "session.status", + properties: { + sessionID: "http://127.0.0.1:9999/session", + status: { type: "busy" }, + }, + }); + lateIdle.resolve({ + id: "evt-late-idle-after-interrupt", + type: "session.status", + properties: { + sessionID: "http://127.0.0.1:9999/session", + status: { type: "idle" }, + }, + }); + yield* Effect.yieldNow; + + const sessions = yield* adapter.listSessions(); + const session = sessions.find((candidate) => candidate.threadId === threadId); + NodeAssert.equal(session?.status, "ready"); + NodeAssert.equal(session?.activeTurnId, undefined); + + yield* adapter.stopSession(threadId); + const events = Array.from(yield* Fiber.join(eventsFiber).pipe(Effect.timeout("1 second"))); + NodeAssert.deepEqual( + events + .filter((event) => event.type === "turn.completed" || event.type === "turn.aborted") + .map((event) => event.type), + ["turn.aborted"], + ); + }), + ); + + it.effect("rejects a prompt accepted after its turn was interrupted", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-interrupt-during-prompt-admission"); + const promptStarted = promiseWithResolvers(); + const promptRelease = promiseWithResolvers(); + const lateBusy = promiseWithResolvers(); + const lateMessage = promiseWithResolvers(); + const latePart = promiseWithResolvers(); + const lateIdle = promiseWithResolvers(); + const marker = promiseWithResolvers(); + runtimeMock.state.autoPromptEcho = false; + runtimeMock.state.subscribedEvents = [ + lateBusy.promise, + lateMessage.promise, + latePart.promise, + lateIdle.promise, + marker.promise, + ]; + runtimeMock.state.promptAsyncImplementation = async () => { + if (runtimeMock.state.promptCalls.length === 1) { + promptStarted.resolve(undefined); + await promptRelease.promise; + } + }; + + const firstLateOutput = yield* adapter.streamEvents.pipe( + Stream.filter( + (event) => + event.threadId === threadId && + (event.type === "content.delta" || event.type === "thread.metadata.updated"), + ), + Stream.runHead, + Effect.forkChild, + ); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + const sendFiber = yield* adapter + .sendTurn({ + threadId, + input: "This request is still pending", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }) + .pipe(Effect.exit, Effect.forkChild); + yield* Effect.promise(() => promptStarted.promise); + + yield* adapter.interruptTurn(threadId); + NodeAssert.equal(runtimeMock.state.abortCalls.length, 1); + const sessionsAfterStop = yield* adapter.listSessions(); + const sessionAfterStop = sessionsAfterStop.find( + (candidate) => candidate.threadId === threadId, + ); + NodeAssert.equal(sessionAfterStop?.status, "ready"); + NodeAssert.equal(sessionAfterStop?.activeTurnId, undefined); + + promptRelease.resolve(undefined); + const sendResult = yield* Fiber.join(sendFiber); + lateBusy.resolve({ + id: "evt-busy-after-late-prompt-acceptance", + type: "session.status", + properties: { + sessionID: "http://127.0.0.1:9999/session", + status: { type: "busy" }, + }, + }); + lateMessage.resolve({ + id: "evt-assistant-after-late-prompt-acceptance", + type: "message.updated", + properties: { + sessionID: "http://127.0.0.1:9999/session", + info: { id: "msg-late-assistant", role: "assistant" }, + }, + }); + latePart.resolve({ + id: "evt-part-after-late-prompt-acceptance", + type: "message.part.updated", + properties: { + sessionID: "http://127.0.0.1:9999/session", + part: { + id: "part-late-assistant", + sessionID: "http://127.0.0.1:9999/session", + messageID: "msg-late-assistant", + type: "text", + text: "Late output", + time: { start: 1 }, + }, + time: 1, + }, + }); + lateIdle.resolve({ + id: "evt-idle-after-late-prompt-acceptance", + type: "session.status", + properties: { + sessionID: "http://127.0.0.1:9999/session", + status: { type: "idle" }, + }, + }); + marker.resolve({ + id: "evt-marker-after-late-prompt-acceptance", + type: "session.updated", + properties: { + info: { + id: "http://127.0.0.1:9999/session", + title: "Late prompt cleaned up", + }, + }, + }); + + const firstOutput = Option.getOrUndefined( + yield* Fiber.join(firstLateOutput).pipe(Effect.timeout("1 second")), + ); + NodeAssert.equal(firstOutput?.type, "thread.metadata.updated"); + NodeAssert.equal(Exit.isFailure(sendResult), true); + if (Exit.isFailure(sendResult)) { + NodeAssert.equal(Cause.hasInterruptsOnly(sendResult.cause), true); + } + + yield* adapter.sendTurn({ + threadId, + input: "Start after late cleanup", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }); + yield* Effect.yieldNow; + NodeAssert.equal(runtimeMock.state.abortCalls.length, 1); + const sessionsAfterNextTurn = yield* adapter.listSessions(); + const sessionAfterNextTurn = sessionsAfterNextTurn.find( + (candidate) => candidate.threadId === threadId, + ); + NodeAssert.equal(sessionAfterNextTurn?.status, "running"); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("treats MessageAbortedError as the acknowledgment for a pending user stop", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-interrupt-error-race"); + const abortedEvent = promiseWithResolvers(); + const abortStarted = promiseWithResolvers(); + const abortRelease = promiseWithResolvers(); + runtimeMock.state.subscribedEvents = [abortedEvent.promise]; + runtimeMock.state.abortImplementation = async () => { + abortStarted.resolve(undefined); + await abortRelease.promise; + }; + + const eventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId), + Stream.take(4), + Stream.runCollect, + Effect.forkChild, + ); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + const turn = yield* adapter.sendTurn({ + threadId, + input: "Keep working", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }); + + const interruptFiber = yield* adapter + .interruptTurn(threadId, turn.turnId) + .pipe(Effect.forkChild); + yield* Effect.promise(() => abortStarted.promise); + abortedEvent.resolve({ + id: "evt-aborted-after-stop", + type: "session.error", + properties: { + sessionID: "http://127.0.0.1:9999/session", + error: { name: "MessageAbortedError", data: { message: "Aborted" } }, + }, + }); + yield* Effect.yieldNow; + abortRelease.resolve(undefined); + yield* Fiber.join(interruptFiber); + + const events = Array.from(yield* Fiber.join(eventsFiber).pipe(Effect.timeout("1 second"))); + NodeAssert.deepEqual( + events + .filter( + (event) => + event.type === "turn.completed" || + event.type === "turn.aborted" || + event.type === "runtime.error", + ) + .map((event) => event.type), + ["turn.aborted"], + ); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("does not claim a turn stopped when the abort request fails", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-interrupt-request-failure"); + runtimeMock.state.abortImplementation = async () => { + throw new Error("abort failed"); + }; + + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + const turn = yield* adapter.sendTurn({ + threadId, + input: "Keep working", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }); + + const exit = yield* Effect.exit(adapter.interruptTurn(threadId, turn.turnId)); + NodeAssert.equal(Exit.isFailure(exit), true); + const sessions = yield* adapter.listSessions(); + const session = sessions.find((candidate) => candidate.threadId === threadId); + NodeAssert.equal(session?.status, "running"); + NodeAssert.equal(session?.activeTurnId, turn.turnId); + }), + ); + + it.effect("releases stop and send waiters when a native abort times out", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-interrupt-timeout"); + const abortStarted = promiseWithResolvers(); + runtimeMock.state.abortImplementation = async () => { + abortStarted.resolve(undefined); + await new Promise(() => {}); + }; + runtimeMock.state.sessionStatus = "busy"; + + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + const turn = yield* adapter.sendTurn({ + threadId, + input: "Keep working", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }); + const unexpectedEventFiber = yield* adapter.streamEvents.pipe( + Stream.filter( + (event) => + event.threadId === threadId && + (event.type === "turn.completed" || event.type === "turn.aborted"), + ), + Stream.runHead, + Effect.forkChild, + ); + const firstInterrupt = yield* adapter + .interruptTurn(threadId, turn.turnId) + .pipe(Effect.result, Effect.forkChild); + yield* Effect.promise(() => abortStarted.promise); + NodeAssert.equal(runtimeMock.state.abortCalls.length, 1); + NodeAssert.equal(runtimeMock.state.abortSignals.length, 1); + const abortSignal = runtimeMock.state.abortSignals[0]; + const secondInterrupt = yield* adapter + .interruptTurn(threadId, turn.turnId) + .pipe(Effect.result, Effect.forkChild); + const sendFiber = yield* adapter + .sendTurn({ + threadId, + input: "Wait for the stop request", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }) + .pipe(Effect.result, Effect.forkChild); + yield* Effect.yieldNow; + NodeAssert.equal(runtimeMock.state.abortCalls.length, 1); + + yield* advanceTestClock(9_999); + NodeAssert.equal(firstInterrupt.pollUnsafe(), undefined); + NodeAssert.equal(secondInterrupt.pollUnsafe(), undefined); + NodeAssert.equal(sendFiber.pollUnsafe(), undefined); + yield* advanceTestClock(1); + + const firstResult = yield* Fiber.join(firstInterrupt); + const secondResult = yield* Fiber.join(secondInterrupt); + const sendResult = yield* Fiber.join(sendFiber); + NodeAssert.equal(firstResult._tag, "Failure"); + NodeAssert.equal(secondResult._tag, "Failure"); + NodeAssert.equal(sendResult._tag, "Failure"); + if (firstResult._tag === "Failure") { + NodeAssert.equal(firstResult.failure._tag, "ProviderAdapterRequestError"); + NodeAssert.equal( + firstResult.failure.detail, + "OpenCode session abort did not complete within 10 seconds.", + ); + } + NodeAssert.equal(abortSignal?.aborted, true); + NodeAssert.equal(unexpectedEventFiber.pollUnsafe(), undefined); + + runtimeMock.state.abortImplementation = null; + yield* adapter.sendTurn({ + threadId, + input: "Continue after the failed stop request", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }); + NodeAssert.equal(runtimeMock.state.promptCalls.length, 2); + + yield* Fiber.interrupt(unexpectedEventFiber); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("shares one abort request across concurrent stops", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-concurrent-interrupt"); + const abortStarted = promiseWithResolvers(); + const abortRelease = promiseWithResolvers(); + runtimeMock.state.abortImplementation = async () => { + abortStarted.resolve(undefined); + await abortRelease.promise; + }; + + const eventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId), + Stream.take(4), + Stream.runCollect, + Effect.forkChild, + ); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + const turn = yield* adapter.sendTurn({ + threadId, + input: "Keep working", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }); + + const firstInterrupt = yield* adapter + .interruptTurn(threadId, turn.turnId) + .pipe(Effect.forkChild); + const secondInterrupt = yield* adapter + .interruptTurn(threadId, turn.turnId) + .pipe(Effect.forkChild); + yield* Effect.promise(() => abortStarted.promise); + yield* Effect.yieldNow; + NodeAssert.equal(runtimeMock.state.abortCalls.length, 1); + + abortRelease.resolve(undefined); + yield* Fiber.join(firstInterrupt); + yield* Fiber.join(secondInterrupt); + + const events = Array.from(yield* Fiber.join(eventsFiber).pipe(Effect.timeout("1 second"))); + NodeAssert.deepEqual( + events + .filter((event) => event.type === "turn.completed" || event.type === "turn.aborted") + .map((event) => event.type), + ["turn.aborted"], + ); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("accepts a native turnless abort before its request times out", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-turnless-interrupt"); + const abortEvent = promiseWithResolvers(); + const markerEvent = promiseWithResolvers(); + const abortStarted = promiseWithResolvers(); + runtimeMock.state.subscribedEvents = [abortEvent.promise, markerEvent.promise]; + runtimeMock.state.abortImplementation = async () => { + abortStarted.resolve(undefined); + await new Promise(() => {}); + }; + + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + resumeCursor: { schemaVersion: 1, sessionId: "ses_existing" }, + }); + const acknowledgmentFiber = yield* adapter.streamEvents.pipe( + Stream.filter( + (event) => + event.threadId === threadId && + (event.type === "turn.completed" || + event.type === "turn.aborted" || + event.type === "runtime.error" || + event.type === "thread.metadata.updated"), + ), + Stream.runHead, + Effect.forkChild, + ); + const firstInterrupt = yield* adapter.interruptTurn(threadId).pipe(Effect.forkChild); + yield* Effect.promise(() => abortStarted.promise); + const secondInterrupt = yield* adapter.interruptTurn(threadId).pipe(Effect.forkChild); + runtimeMock.state.sessionStatusImplementation = async () => ({ + data: { ses_existing: { type: "busy" as const } }, + }); + const sendFiber = yield* adapter + .sendTurn({ + threadId, + input: "Start after the session abort", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }) + .pipe(Effect.forkChild); + yield* Effect.yieldNow; + + NodeAssert.equal(runtimeMock.state.abortCalls.length, 1); + NodeAssert.equal(runtimeMock.state.promptCalls.length, 0); + + abortEvent.resolve({ + id: "evt-turnless-abort", + type: "session.error", + properties: { + sessionID: "ses_existing", + error: { name: "MessageAbortedError", data: { message: "Aborted" } }, + }, + }); + markerEvent.resolve({ + id: "evt-after-turnless-abort", + type: "session.updated", + properties: { + info: { id: "ses_existing", title: "Turnless abort acknowledged" }, + }, + }); + const acknowledgment = Option.getOrUndefined(yield* Fiber.join(acknowledgmentFiber)); + NodeAssert.equal(acknowledgment?.type, "thread.metadata.updated"); + const unexpectedEventFiber = yield* adapter.streamEvents.pipe( + Stream.filter( + (event) => + event.threadId === threadId && + (event.type === "turn.completed" || + event.type === "turn.aborted" || + event.type === "runtime.error"), + ), + Stream.runHead, + Effect.forkChild, + ); + yield* advanceTestClock(10_000); + yield* Fiber.join(firstInterrupt); + yield* Fiber.join(secondInterrupt); + yield* Fiber.join(sendFiber); + + NodeAssert.equal(runtimeMock.state.promptCalls.length, 1); + NodeAssert.equal(runtimeMock.state.abortSignals[0]?.aborted, true); + NodeAssert.equal(unexpectedEventFiber.pollUnsafe(), undefined); + yield* Fiber.interrupt(unexpectedEventFiber); + runtimeMock.state.abortImplementation = null; + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("ignores a native turnless abort after its request succeeds", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-late-turnless-abort"); + const abortEvent = promiseWithResolvers(); + const markerEvent = promiseWithResolvers(); + runtimeMock.state.subscribedEvents = [abortEvent.promise, markerEvent.promise]; + + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + resumeCursor: { schemaVersion: 1, sessionId: "ses_existing" }, + }); + const acknowledgmentFiber = yield* adapter.streamEvents.pipe( + Stream.filter( + (event) => + event.threadId === threadId && + (event.type === "turn.completed" || + event.type === "turn.aborted" || + event.type === "runtime.error" || + event.type === "thread.metadata.updated"), + ), + Stream.runHead, + Effect.forkChild, + ); + + yield* adapter.interruptTurn(threadId); + abortEvent.resolve({ + id: "evt-late-turnless-abort", + type: "session.error", + properties: { + sessionID: "ses_existing", + error: { name: "MessageAbortedError", data: { message: "Aborted" } }, + }, + }); + markerEvent.resolve({ + id: "evt-after-late-turnless-abort", + type: "session.updated", + properties: { + info: { id: "ses_existing", title: "Late turnless abort ignored" }, + }, + }); + const acknowledgment = Option.getOrUndefined(yield* Fiber.join(acknowledgmentFiber)); + + NodeAssert.equal(acknowledgment?.type, "thread.metadata.updated"); + const sessions = yield* adapter.listSessions(); + const session = sessions.find((candidate) => candidate.threadId === threadId); + NodeAssert.equal(session?.status, "ready"); + NodeAssert.equal(session?.activeTurnId, undefined); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("clears a failed turnless interrupt before the next turn", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-turnless-interrupt-failure"); + runtimeMock.state.abortImplementation = async () => { + throw new Error("abort failed"); + }; + + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + resumeCursor: { schemaVersion: 1, sessionId: "ses_existing" }, + }); + const interruptExit = yield* Effect.exit(adapter.interruptTurn(threadId)); + NodeAssert.equal(Exit.isFailure(interruptExit), true); + + runtimeMock.state.abortImplementation = null; + yield* adapter.sendTurn({ + threadId, + input: "Start after the failed session abort", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }); + NodeAssert.equal(runtimeMock.state.promptCalls.length, 1); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("waits for a pending stop before starting the next turn", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-send-during-stop"); + const abortStarted = promiseWithResolvers(); + const abortRelease = promiseWithResolvers(); + runtimeMock.state.abortImplementation = async () => { + abortStarted.resolve(undefined); + await abortRelease.promise; + }; + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + const stoppedTurn = yield* adapter.sendTurn({ + threadId, + input: "First turn", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }); + const stopFiber = yield* adapter + .interruptTurn(threadId, stoppedTurn.turnId) + .pipe(Effect.forkChild); + yield* Effect.promise(() => abortStarted.promise); + const sendFiber = yield* adapter + .sendTurn({ + threadId, + input: "Second turn", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }) + .pipe(Effect.forkChild); + yield* Effect.yieldNow; + NodeAssert.equal(runtimeMock.state.promptCalls.length, 1); + abortRelease.resolve(undefined); + yield* Fiber.join(stopFiber); + const nextTurn = yield* Fiber.join(sendFiber); + + NodeAssert.notEqual(nextTurn.turnId, stoppedTurn.turnId); + NodeAssert.equal(runtimeMock.state.promptCalls.length, 2); + }), + ); + + it.effect("interrupts a turn waiting on cancellation when the session stops", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-stop-during-cancellation"); + const firstAbortStarted = promiseWithResolvers(); + const teardownAbortStarted = promiseWithResolvers(); + const abortRelease = promiseWithResolvers(); + runtimeMock.state.abortImplementation = async () => { + if (runtimeMock.state.abortCalls.length === 1) { + firstAbortStarted.resolve(undefined); + } else { + teardownAbortStarted.resolve(undefined); + } + await abortRelease.promise; + }; + + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + const activeTurn = yield* adapter.sendTurn({ + threadId, + input: "First turn", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }); + const interruptFiber = yield* adapter + .interruptTurn(threadId, activeTurn.turnId) + .pipe(Effect.forkChild); + yield* Effect.promise(() => firstAbortStarted.promise); + + const sendFiber = yield* adapter + .sendTurn({ + threadId, + input: "Must not be sent", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }) + .pipe(Effect.exit, Effect.forkChild); + yield* Effect.yieldNow; + NodeAssert.equal(runtimeMock.state.promptCalls.length, 1); + + const stopFiber = yield* adapter.stopSession(threadId).pipe(Effect.forkChild); + const sendResult = yield* Fiber.join(sendFiber); + NodeAssert.equal(Exit.isFailure(sendResult), true); + if (Exit.isFailure(sendResult)) { + NodeAssert.equal(Cause.hasInterruptsOnly(sendResult.cause), true); + } + NodeAssert.equal(runtimeMock.state.promptCalls.length, 1); + + yield* Effect.promise(() => teardownAbortStarted.promise); + yield* advanceTestClock(1_000); + yield* Fiber.join(stopFiber); + NodeAssert.equal(yield* adapter.hasSession(threadId), false); + + abortRelease.resolve(undefined); + yield* Fiber.join(interruptFiber); + NodeAssert.equal(runtimeMock.state.promptCalls.length, 1); + NodeAssert.equal(yield* adapter.hasSession(threadId), false); + }), + ); + + it.effect("rechecks a newer idle after an older status call returns busy", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-newer-idle-during-status"); + const busyEvent = promiseWithResolvers(); + const staleIdle = promiseWithResolvers(); + const realIdle = promiseWithResolvers(); + const statusStarted = promiseWithResolvers(); + const statusRelease = promiseWithResolvers(); + runtimeMock.state.subscribedEvents = [busyEvent.promise, staleIdle.promise, realIdle.promise]; + runtimeMock.state.sessionStatusImplementation = async () => { + if (runtimeMock.state.sessionStatusCalls === 1) { + statusStarted.resolve(undefined); + await statusRelease.promise; + return { + data: { "http://127.0.0.1:9999/session": { type: "busy" as const } }, + }; + } + return { data: {} }; + }; + + const completedFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId && event.type === "turn.completed"), + Stream.runHead, + Effect.forkChild, + ); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + const firstTurn = yield* adapter.sendTurn({ + threadId, + input: "First turn", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }); + yield* adapter.interruptTurn(threadId, firstTurn.turnId); + const secondTurn = yield* adapter.sendTurn({ + threadId, + input: "Second turn", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }); + busyEvent.resolve({ + id: "evt-new-turn-busy", + type: "session.status", + properties: { + sessionID: "http://127.0.0.1:9999/session", + status: { type: "busy" }, + }, + }); + staleIdle.resolve({ + id: "evt-old-idle", + type: "session.status", + properties: { + sessionID: "http://127.0.0.1:9999/session", + status: { type: "idle" }, + }, + }); + yield* Effect.promise(() => statusStarted.promise); + realIdle.resolve({ + id: "evt-new-idle", + type: "session.status", + properties: { + sessionID: "http://127.0.0.1:9999/session", + status: { type: "idle" }, + }, + }); + statusRelease.resolve(undefined); + + const completed = Option.getOrUndefined( + yield* Fiber.join(completedFiber).pipe(Effect.timeout("1 second")), + ); + NodeAssert.equal(completed?.turnId, secondTurn.turnId); + NodeAssert.equal(runtimeMock.state.sessionStatusCalls, 2); + }), + ); + + it.effect("completes after transient status failures without another idle event", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-idle-status-retry"); + const busyEvent = promiseWithResolvers(); + const idleEvent = promiseWithResolvers(); + const failuresObserved = promiseWithResolvers(); + runtimeMock.state.subscribedEvents = [busyEvent.promise, idleEvent.promise]; + runtimeMock.state.sessionStatusImplementation = async () => { + if (runtimeMock.state.sessionStatusCalls <= 2) { + if (runtimeMock.state.sessionStatusCalls === 2) { + failuresObserved.resolve(undefined); + } + throw new Error("status failed"); + } + return { data: {} }; + }; + + const completedFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId && event.type === "turn.completed"), + Stream.runHead, + Effect.forkChild, + ); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + const firstTurn = yield* adapter.sendTurn({ + threadId, + input: "First turn", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }); + yield* adapter.interruptTurn(threadId, firstTurn.turnId); + const secondTurn = yield* adapter.sendTurn({ + threadId, + input: "Second turn", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }); + busyEvent.resolve({ + id: "evt-retry-turn-busy", + type: "session.status", + properties: { + sessionID: "http://127.0.0.1:9999/session", + status: { type: "busy" }, + }, + }); + idleEvent.resolve({ + id: "evt-retry-turn-idle", + type: "session.status", + properties: { + sessionID: "http://127.0.0.1:9999/session", + status: { type: "idle" }, + }, + }); + yield* Effect.promise(() => failuresObserved.promise); + yield* advanceTestClock(250); + + const completed = Option.getOrUndefined( + yield* Fiber.join(completedFiber).pipe(Effect.timeout("1 second")), + ); + NodeAssert.equal(completed?.turnId, secondTurn.turnId); + NodeAssert.equal(runtimeMock.state.sessionStatusCalls, 3); + }), + ); + + it.effect("keeps idle reconciliation after a delayed abort from the stopped turn", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-stale-abort-during-idle-check"); + const busyEvent = promiseWithResolvers(); + const idleEvent = promiseWithResolvers(); + const staleAbortEvent = promiseWithResolvers(); + const statusStarted = promiseWithResolvers(); + const statusRelease = promiseWithResolvers(); + runtimeMock.state.subscribedEvents = [ + busyEvent.promise, + idleEvent.promise, + staleAbortEvent.promise, + ]; + runtimeMock.state.sessionStatusImplementation = async () => { + statusStarted.resolve(undefined); + await statusRelease.promise; + return { data: {} }; + }; + + const completedFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId && event.type === "turn.completed"), + Stream.runHead, + Effect.forkChild, + ); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + const stoppedTurn = yield* adapter.sendTurn({ + threadId, + input: "First turn", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }); + yield* adapter.interruptTurn(threadId, stoppedTurn.turnId); + const activeTurn = yield* adapter.sendTurn({ + threadId, + input: "Second turn", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }); + busyEvent.resolve({ + id: "evt-stale-abort-busy", + type: "session.status", + properties: { + sessionID: "http://127.0.0.1:9999/session", + status: { type: "busy" }, + }, + }); + idleEvent.resolve({ + id: "evt-stale-abort-idle", + type: "session.status", + properties: { + sessionID: "http://127.0.0.1:9999/session", + status: { type: "idle" }, + }, + }); + yield* Effect.promise(() => statusStarted.promise); + staleAbortEvent.resolve({ + id: "evt-delayed-old-abort", + type: "session.error", + properties: { + sessionID: "http://127.0.0.1:9999/session", + error: { name: "MessageAbortedError", data: { message: "Aborted" } }, + }, + }); + statusRelease.resolve(undefined); + + const completed = Option.getOrUndefined( + yield* Fiber.join(completedFiber).pipe(Effect.timeout("1 second")), + ); + NodeAssert.equal(completed?.turnId, activeTurn.turnId); + }), + ); + + it.effect("keeps the newer turn running while status lookup keeps failing", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-idle-status-permanent-failure"); + const busyEvent = promiseWithResolvers(); + const idleEvent = promiseWithResolvers(); + const firstAttemptFailed = promiseWithResolvers(); + const retryAttemptFailed = promiseWithResolvers(); + runtimeMock.state.subscribedEvents = [busyEvent.promise, idleEvent.promise]; + runtimeMock.state.sessionStatusImplementation = async () => { + if (runtimeMock.state.sessionStatusCalls === 2) { + firstAttemptFailed.resolve(undefined); + } + if (runtimeMock.state.sessionStatusCalls === 4) { + retryAttemptFailed.resolve(undefined); + } + throw new Error("status remains unavailable"); + }; + + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + const stoppedTurn = yield* adapter.sendTurn({ + threadId, + input: "First turn", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }); + yield* adapter.interruptTurn(threadId, stoppedTurn.turnId); + const activeTurn = yield* adapter.sendTurn({ + threadId, + input: "Second turn", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }); + busyEvent.resolve({ + id: "evt-permanent-failure-busy", + type: "session.status", + properties: { + sessionID: "http://127.0.0.1:9999/session", + status: { type: "busy" }, + }, + }); + idleEvent.resolve({ + id: "evt-permanent-failure-idle", + type: "session.status", + properties: { + sessionID: "http://127.0.0.1:9999/session", + status: { type: "idle" }, + }, + }); + yield* Effect.promise(() => firstAttemptFailed.promise); + yield* advanceTestClock(250); + yield* Effect.promise(() => retryAttemptFailed.promise); + + const sessions = yield* adapter.listSessions(); + const session = sessions.find((candidate) => candidate.threadId === threadId); + NodeAssert.equal(session?.status, "running"); + NodeAssert.equal(session?.activeTurnId, activeTurn.turnId); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("ignores delayed stop events around the next turn startup", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-delayed-interrupt-events"); + const staleIdleBeforeBusy = promiseWithResolvers(); + const nextBusy = promiseWithResolvers(); + const nextUserMessage = promiseWithResolvers(); + const staleAbort = promiseWithResolvers(); + const staleIdle = promiseWithResolvers(); + const secondStaleIdle = promiseWithResolvers(); + const nextIdle = promiseWithResolvers(); + runtimeMock.state.autoPromptEcho = false; + runtimeMock.state.subscribedEvents = [ + staleIdleBeforeBusy.promise, + nextBusy.promise, + nextUserMessage.promise, + staleAbort.promise, + staleIdle.promise, + secondStaleIdle.promise, + nextIdle.promise, + ]; + + const eventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId), + Stream.take(6), + Stream.runCollect, + Effect.forkChild, + ); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + const firstTurn = yield* adapter.sendTurn({ + threadId, + input: "First turn", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }); + yield* adapter.interruptTurn(threadId, firstTurn.turnId); + const secondTurn = yield* adapter.sendTurn({ + threadId, + input: "Second turn", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }); + const secondMessageId = (runtimeMock.state.promptCalls.at(-1) as { messageID: string }) + .messageID; + + staleIdleBeforeBusy.resolve({ + id: "evt-delayed-idle-before-busy", + type: "session.status", + properties: { + sessionID: "http://127.0.0.1:9999/session", + status: { type: "idle" }, + }, + }); + for (let index = 0; index < 2; index += 1) { + yield* Effect.yieldNow; + } + const sessionsBeforeBusy = yield* adapter.listSessions(); + const sessionBeforeBusy = sessionsBeforeBusy.find( + (candidate) => candidate.threadId === threadId, + ); + NodeAssert.equal(sessionBeforeBusy?.status, "running"); + NodeAssert.equal(sessionBeforeBusy?.activeTurnId, secondTurn.turnId); + + runtimeMock.state.sessionStatus = "busy"; + nextBusy.resolve({ + id: "evt-next-busy", + type: "session.status", + properties: { + sessionID: "http://127.0.0.1:9999/session", + status: { type: "busy" }, + }, + }); + nextUserMessage.resolve({ + id: "evt-next-user-message", + type: "message.updated", + properties: { + sessionID: "http://127.0.0.1:9999/session", + info: { id: secondMessageId, role: "user" }, + }, + }); + staleAbort.resolve({ + id: "evt-delayed-abort", + type: "session.error", + properties: { + sessionID: "http://127.0.0.1:9999/session", + error: { name: "MessageAbortedError", data: { message: "Aborted" } }, + }, + }); + staleIdle.resolve({ + id: "evt-delayed-idle", + type: "session.status", + properties: { + sessionID: "http://127.0.0.1:9999/session", + status: { type: "idle" }, + }, + }); + secondStaleIdle.resolve({ + id: "evt-second-delayed-idle", + type: "session.status", + properties: { + sessionID: "http://127.0.0.1:9999/session", + status: { type: "idle" }, + }, + }); + for (let index = 0; index < 4; index += 1) { + yield* Effect.yieldNow; + } + + const sessionsBeforeRealIdle = yield* adapter.listSessions(); + const sessionBeforeRealIdle = sessionsBeforeRealIdle.find( + (candidate) => candidate.threadId === threadId, + ); + NodeAssert.equal(sessionBeforeRealIdle?.status, "running"); + NodeAssert.equal(sessionBeforeRealIdle?.activeTurnId, secondTurn.turnId); + + runtimeMock.state.sessionStatus = "idle"; + nextIdle.resolve({ + id: "evt-next-idle", + type: "session.status", + properties: { + sessionID: "http://127.0.0.1:9999/session", + status: { type: "idle" }, + }, + }); + + const events = Array.from(yield* Fiber.join(eventsFiber).pipe(Effect.timeout("1 second"))); + NodeAssert.deepEqual( + events + .filter( + (event) => + event.type === "turn.completed" || + event.type === "turn.aborted" || + event.type === "runtime.error", + ) + .map((event) => ({ type: event.type, turnId: event.turnId })), + [ + { type: "turn.aborted", turnId: firstTurn.turnId }, + { type: "turn.completed", turnId: secondTurn.turnId }, + ], + ); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("keeps a genuine provider error visible during a pending user stop", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-interrupt-provider-error"); + const errorEvent = promiseWithResolvers(); + const abortStarted = promiseWithResolvers(); + const childAbortStarted = promiseWithResolvers(); + const childAbortRelease = promiseWithResolvers(); + const rootSessionId = "http://127.0.0.1:9999/session"; + runtimeMock.state.subscribedEvents = [errorEvent.promise]; + runtimeMock.state.sessionChildrenById.set(rootSessionId, [{ id: "ses_error_child" }]); + runtimeMock.state.abortImplementation = async (sessionID) => { + if (sessionID === rootSessionId) { + abortStarted.resolve(undefined); + await new Promise(() => {}); + } + if (sessionID === "ses_error_child") { + childAbortStarted.resolve(undefined); + await childAbortRelease.promise; + } + }; + + const eventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId), + Stream.take(5), + Stream.runCollect, + Effect.forkChild, + ); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + const turn = yield* adapter.sendTurn({ + threadId, + input: "Keep working", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }); + + const interruptFiber = yield* adapter + .interruptTurn(threadId, turn.turnId) + .pipe(Effect.forkChild); + yield* Effect.promise(() => abortStarted.promise); + errorEvent.resolve({ + id: "evt-provider-error-after-stop", + type: "session.error", + properties: { + sessionID: rootSessionId, + error: { + name: "APIError", + data: { message: "Upstream failed", isRetryable: false }, + }, + }, + }); + yield* Effect.promise(() => childAbortStarted.promise); + + const events = Array.from(yield* Fiber.join(eventsFiber).pipe(Effect.timeout("1 second"))); + NodeAssert.deepEqual( + events + .filter( + (event) => + event.type === "turn.completed" || + event.type === "turn.aborted" || + event.type === "runtime.error", + ) + .map((event) => event.type), + ["turn.completed", "runtime.error"], + ); + const failed = events.find((event) => event.type === "turn.completed"); + NodeAssert.equal( + failed?.type === "turn.completed" ? failed.payload.state : undefined, + "failed", + ); + const sessionsDuringCleanup = yield* adapter.listSessions(); + const sessionDuringCleanup = sessionsDuringCleanup.find( + (candidate) => candidate.threadId === threadId, + ); + NodeAssert.equal(sessionDuringCleanup?.status, "error"); + NodeAssert.equal(sessionDuringCleanup?.activeTurnId, undefined); + + const secondInterruptFiber = yield* adapter.interruptTurn(threadId).pipe(Effect.forkChild); + const nextTurnFiber = yield* adapter + .sendTurn({ + threadId, + input: "Start after child cleanup", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }) + .pipe(Effect.forkChild); + yield* Effect.yieldNow; + NodeAssert.equal( + runtimeMock.state.abortCalls.filter((sessionID) => sessionID === rootSessionId).length, + 1, + ); + NodeAssert.equal(secondInterruptFiber.pollUnsafe(), undefined); + NodeAssert.equal(nextTurnFiber.pollUnsafe(), undefined); + NodeAssert.equal(runtimeMock.state.promptCalls.length, 1); + + childAbortRelease.resolve(undefined); + yield* Fiber.join(interruptFiber); + yield* Fiber.join(secondInterruptFiber); + const nextTurn = yield* Fiber.join(nextTurnFiber); + NodeAssert.notEqual(nextTurn.turnId, turn.turnId); + NodeAssert.equal(runtimeMock.state.promptCalls.length, 2); + + runtimeMock.state.abortImplementation = null; + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("passes agent and variant options for the adapter's bound custom instance id", () => { + const instanceId = ProviderInstanceId.make("opencode_zen"); + const adapterLayer = Layer.effect( + OpenCodeAdapter, + makeOpenCodeAdapter(openCodeAdapterTestSettings, { instanceId }), + ).pipe( + Layer.provideMerge(Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble)), + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), + Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provideMerge(providerSessionDirectoryTestLayer), + Layer.provideMerge(NodeServices.layer), + ); + + return Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId: asThreadId("thread-custom-instance"), + runtimeMode: "full-access", + }); + + yield* adapter.sendTurn({ + threadId: asThreadId("thread-custom-instance"), + input: "Fix it", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode_zen"), + "anthropic/claude-sonnet-4-5", + [ + { id: "agent", value: "github-copilot" }, + { id: "variant", value: "high" }, + ], + ), + }); + + const { messageID, ...prompt } = runtimeMock.state.promptCalls.at(-1) as { + messageID: string; + [key: string]: unknown; + }; + NodeAssert.match(messageID, /^msg_[0-9a-f]{12}[0-9A-Za-z]{14}$/); + NodeAssert.deepEqual(prompt, { + sessionID: "http://127.0.0.1:9999/session", + model: { + providerID: "anthropic", + modelID: "claude-sonnet-4-5", + }, + agent: "github-copilot", + variant: "high", + parts: [{ type: "text", text: "Fix it" }], + }); + }).pipe(Effect.provide(adapterLayer)); + }); + + it.effect("uses the bound custom instance id for fallback sendTurn model selection", () => { + const instanceId = ProviderInstanceId.make("opencode_zen"); + const adapterLayer = Layer.effect( + OpenCodeAdapter, + makeOpenCodeAdapter(openCodeAdapterTestSettings, { instanceId }), + ).pipe( + Layer.provideMerge(Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble)), + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), + Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provideMerge(providerSessionDirectoryTestLayer), + Layer.provideMerge(NodeServices.layer), + ); + + return Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-custom-instance-fallback-model"); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode_zen"), + "anthropic/claude-sonnet-4-5", + ), + }); + + yield* adapter.sendTurn({ + threadId, + input: "Fix it", + }); + + const { messageID, ...prompt } = runtimeMock.state.promptCalls.at(-1) as { + messageID: string; + [key: string]: unknown; + }; + NodeAssert.match(messageID, /^msg_[0-9a-f]{12}[0-9A-Za-z]{14}$/); + NodeAssert.deepEqual(prompt, { + sessionID: "http://127.0.0.1:9999/session", + model: { + providerID: "anthropic", modelID: "claude-sonnet-4-5", }, parts: [{ type: "text", text: "Fix it" }], @@ -1060,12 +4924,27 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { const firstUpdate = mergeOpenCodeAssistantText(undefined, "Hello"); const overlapDelta = appendOpenCodeAssistantTextDelta(firstUpdate.latestText, "lo world"); const secondUpdate = mergeOpenCodeAssistantText(overlapDelta.nextText, "Hellolo world"); + const appendedUpdate = mergeOpenCodeAssistantText("Hello", "Hello world"); + const changedUpdate = mergeOpenCodeAssistantText("Hello world", "Hello there"); + const staleUpdate = mergeOpenCodeAssistantText("Hello world", "Hello"); NodeAssert.deepEqual( [firstUpdate.deltaToEmit, overlapDelta.deltaToEmit, secondUpdate.deltaToEmit], ["Hello", "lo world", ""], ); NodeAssert.equal(secondUpdate.latestText, "Hellolo world"); + NodeAssert.deepEqual(appendedUpdate, { + latestText: "Hello world", + deltaToEmit: " world", + }); + NodeAssert.deepEqual(changedUpdate, { + latestText: "Hello there", + deltaToEmit: "there", + }); + NodeAssert.deepEqual(staleUpdate, { + latestText: "Hello world", + deltaToEmit: "", + }); }), ); @@ -1289,6 +5168,39 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { }, }, }, + { + id: "evt-unrelated-child", + type: "session.created", + properties: { + sessionID: "ses_unrelated_child", + info: { + id: "ses_unrelated_child", + parentID: "ses_unrelated_parent", + title: "Unrelated child", + }, + }, + }, + { + id: "evt-unrelated-permission", + type: "permission.asked", + properties: { + id: "per_unrelated", + sessionID: "ses_unrelated_child", + permission: "bash", + patterns: ["pwd"], + metadata: {}, + always: [], + }, + }, + { + id: "evt-unrelated-question", + type: "question.asked", + properties: { + id: "que_unrelated", + sessionID: "ses_unrelated_child", + questions: [], + }, + }, { type: "message.updated", properties: { diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index 8f7e42c11d7c..d0b4f0de78ce 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -15,13 +15,17 @@ import { import * as Cause from "effect/Cause"; import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; +import * as Fiber from "effect/Fiber"; import * as Path from "effect/Path"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; import type { OpencodeClient, Part, PermissionRequest, QuestionRequest } from "@opencode-ai/sdk/v2"; import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; @@ -175,6 +179,79 @@ type OpenCodeSubscribedEvent = ? TEvent : never; +type OpenCodeSessionStatusEvent = Extract< + OpenCodeSubscribedEvent, + { readonly type: "session.status" } +>; + +const OpenCodeSessionStatusMap = Schema.Record( + Schema.String, + Schema.Struct({ type: Schema.String }), +); +const decodeOpenCodeSessionStatusMap = Schema.decodeUnknownOption(OpenCodeSessionStatusMap); + +interface OpenCodeCancellation { + readonly turnId: TurnId | undefined; + readonly acknowledgment: Deferred.Deferred; + readonly completion: Deferred.Deferred; + acknowledged?: boolean; + turnSettled?: boolean; + deferredIdleEvent?: OpenCodeSessionStatusEvent; +} + +interface OpenCodeIdleReconciliation { + readonly turnId: TurnId; + readonly promptGeneration: number; + raw: unknown; + warned: boolean; + dirty: boolean; + fiber?: Fiber.Fiber; +} + +interface OpenCodePromptAdmission { + readonly generation: number; + readonly turnId: TurnId; + readonly messageId: string; + readonly priorAwaitingBusy: boolean; + readonly priorIdle: { readonly turnId: TurnId; readonly raw: unknown } | undefined; + idleDuringAdmission: { readonly turnId: TurnId; readonly raw: unknown } | undefined; + idleObservedAfterMessage: boolean; + messageObserved: boolean; + busyObserved: boolean; + idleStatusConfirmations: number; + accepted: boolean; + cancelled: boolean; + readonly acceptance: Deferred.Deferred; + readonly submissionSettled: Deferred.Deferred; + promptFiber?: Fiber.Fiber; + recoveryFiber?: Fiber.Fiber; + recoveryRaw: unknown; +} + +type OpenCodeTerminalRequestEvent = Extract< + OpenCodeSubscribedEvent, + { + readonly type: "permission.replied" | "question.replied" | "question.rejected"; + } +>; + +type OpenCodeAskedRequestEvent = Extract< + OpenCodeSubscribedEvent, + { readonly type: "permission.asked" | "question.asked" } +>; + +type OpenCodeRoutedRequestEvent = OpenCodeAskedRequestEvent | OpenCodeTerminalRequestEvent; + +interface OpenCodeRequestRelationRetry { + warned: boolean; + fiber?: Fiber.Fiber; +} + +interface OpenCodePendingRequestRecovery { + warned: boolean; + rerun: boolean; +} + function trimText(value: string | undefined | null): string | undefined { const trimmed = value?.trim(); return trimmed && trimmed.length > 0 ? trimmed : undefined; @@ -214,6 +291,28 @@ function openCodeEventSessionTitle(event: OpenCodeSubscribedEvent): string | und return title; } +function isOpenCodeAbortError(error: unknown): boolean { + return ( + typeof error === "object" && + error !== null && + "name" in error && + error.name === "MessageAbortedError" + ); +} + +function isOpenCodeChildRequestEvent(event: OpenCodeSubscribedEvent): boolean { + switch (event.type) { + case "permission.asked": + case "permission.replied": + case "question.asked": + case "question.replied": + case "question.rejected": + return true; + default: + return false; + } +} + const OPENCODE_DEFAULT_TITLE_PATTERN = /^(New session - |Child session - )\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/; @@ -227,6 +326,10 @@ interface OpenCodeSessionContext { readonly server: OpenCodeServerConnection; readonly directory: string; readonly openCodeSessionId: string; + readonly relatedSessionIds: Set; + readonly resolvedRequestIds: Set; + readonly emittedTerminalRequestIds: Set; + readonly requestRelationRetries: Map; readonly pendingPermissions: Map; readonly pendingQuestions: Map; readonly messageRoleById: Map; @@ -237,6 +340,16 @@ interface OpenCodeSessionContext { activeTurnId: TurnId | undefined; activeAgent: string | undefined; activeVariant: string | undefined; + cancellation: OpenCodeCancellation | undefined; + interruptedTurnId: TurnId | undefined; + reconcileIdleStatus: boolean; + awaitingBusyAfterInterruption: boolean; + pendingIdleReconciliation: OpenCodeIdleReconciliation | undefined; + pendingRequestRecovery: OpenCodePendingRequestRecovery | undefined; + promptGeneration: number; + promptAdmission: OpenCodePromptAdmission | undefined; + readonly promptSemaphore: Semaphore.Semaphore; + readonly firstConnection: Deferred.Deferred; /** * One-shot guard flipped by `stopOpenCodeContext` / `emitUnexpectedExit`. * The session lifecycle is owned by `sessionScope`; this Ref exists only @@ -454,9 +567,13 @@ export function mergeOpenCodeAssistantText( readonly deltaToEmit: string; } { const latestText = resolveLatestAssistantText(previousText, nextText); + const previous = previousText ?? ""; + const prefixLength = latestText.startsWith(previous) + ? previous.length + : commonPrefixLength(previous, latestText); return { latestText, - deltaToEmit: latestText.slice(commonPrefixLength(previousText ?? "", latestText)), + deltaToEmit: latestText.slice(prefixLength), }; } @@ -537,24 +654,177 @@ function updateProviderSession( }, ): Effect.Effect { return Effect.gen(function* () { - const updatedAt = yield* nowIso; - const nextSession = { - ...context.session, - ...patch, - updatedAt, - } as ProviderSession & Record; - const mutableSession = nextSession as Record; - if (options?.clearActiveTurnId) { - delete mutableSession.activeTurnId; - } - if (options?.clearLastError) { - delete mutableSession.lastError; - } - context.session = nextSession; - return nextSession; + return applyProviderSessionUpdate(context, patch, options, yield* nowIso); }); } +function applyProviderSessionUpdate( + context: OpenCodeSessionContext, + patch: Partial, + options: + | { + readonly clearActiveTurnId?: boolean; + readonly clearLastError?: boolean; + } + | undefined, + updatedAt: string, +): ProviderSession { + const nextSession = { + ...context.session, + ...patch, + updatedAt, + } as ProviderSession & Record; + const mutableSession = nextSession as Record; + if (options?.clearActiveTurnId) { + delete mutableSession.activeTurnId; + } + if (options?.clearLastError) { + delete mutableSession.lastError; + } + context.session = nextSession; + return nextSession; +} + +const failPendingOpenCodeCancellation = Effect.fn("failPendingOpenCodeCancellation")(function* ( + context: OpenCodeSessionContext, + detail: string, +) { + const cancellation = context.cancellation; + if (!cancellation) { + return; + } + context.cancellation = undefined; + yield* Deferred.fail( + cancellation.completion, + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session.abort", + detail, + }), + ).pipe(Effect.ignore); +}); + +const abortOpenCodeDescendants = Effect.fn("abortOpenCodeDescendants")(function* ( + context: OpenCodeSessionContext, +) { + const visited = new Set([context.openCodeSessionId]); + const requestSemaphore = Semaphore.makeUnsafe(8); + + const visit = ( + sessionId: string, + abortSession: boolean, + ): Effect.Effect => + Effect.gen(function* () { + let firstFailure: OpenCodeRuntimeError | undefined; + if (abortSession) { + const abortResult = yield* requestSemaphore + .withPermit( + runOpenCodeSdk("session.abort", (signal) => + context.client.session.abort({ sessionID: sessionId }, { signal }), + ), + ) + .pipe( + Effect.catchIf( + (cause) => isOpenCodeNotFound(cause), + () => Effect.void, + ), + Effect.result, + ); + if (abortResult._tag === "Failure") { + firstFailure = abortResult.failure; + } + } + + const childrenResult = yield* requestSemaphore + .withPermit( + runOpenCodeSdk("session.children", (signal) => + context.client.session.children({ sessionID: sessionId }, { signal }), + ), + ) + .pipe( + Effect.catchIf( + (cause) => isOpenCodeNotFound(cause), + () => Effect.void, + ), + Effect.result, + ); + if (childrenResult._tag === "Failure") { + return firstFailure ?? childrenResult.failure; + } + + const children = childrenResult.success?.data ?? []; + const newChildren = children.filter((child) => { + if (visited.has(child.id)) { + return false; + } + visited.add(child.id); + return true; + }); + const childFailures = yield* Effect.forEach(newChildren, (child) => visit(child.id, true), { + concurrency: 8, + }); + firstFailure ??= childFailures.find((failure) => failure !== undefined); + return firstFailure; + }); + + const firstFailure = yield* visit(context.openCodeSessionId, false); + if (firstFailure) { + return yield* firstFailure; + } +}); + +const abortOpenCodeSessionForTeardown = Effect.fn("abortOpenCodeSessionForTeardown")(function* ( + context: OpenCodeSessionContext, +) { + // Stop the parent before the snapshot so it cannot add another child after + // the adapter reads the tree. + yield* runOpenCodeSdk("session.abort", (signal) => + context.client.session.abort({ sessionID: context.openCodeSessionId }, { signal }), + ).pipe(Effect.timeout("1 second"), Effect.ignore({ log: true })); + yield* abortOpenCodeDescendants(context).pipe( + Effect.timeout("1 second"), + Effect.ignore({ log: true }), + ); +}); + +const cancelPendingOpenCodePrompt = Effect.fn("cancelPendingOpenCodePrompt")(function* ( + context: OpenCodeSessionContext, +) { + const admission = context.promptAdmission; + if (!admission) { + return; + } + admission.cancelled = true; + if (admission.promptFiber) { + yield* Fiber.interrupt(admission.promptFiber); + } + yield* Deferred.await(admission.submissionSettled); +}); + +const closeStartingOpenCodeContext = Effect.fn("closeStartingOpenCodeContext")(function* ( + context: OpenCodeSessionContext, + abortRemote: boolean, +) { + if (yield* Ref.getAndSet(context.stopped, true)) { + return; + } + yield* Deferred.fail( + context.firstConnection, + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "event.subscribe", + detail: "OpenCode session startup ended before the event stream connected.", + }), + ).pipe(Effect.ignore); + yield* cancelPendingOpenCodePrompt(context); + yield* failPendingOpenCodeCancellation(context, "OpenCode session startup was cancelled."); + context.promptAdmission = undefined; + if (abortRemote) { + yield* abortOpenCodeSessionForTeardown(context); + } + yield* Scope.close(context.sessionScope, Exit.void).pipe(Effect.ignore); +}); + const stopOpenCodeContext = Effect.fn("stopOpenCodeContext")(function* ( context: OpenCodeSessionContext, ) { @@ -562,13 +832,26 @@ const stopOpenCodeContext = Effect.fn("stopOpenCodeContext")(function* ( if (yield* Ref.getAndSet(context.stopped, true)) { return false; } + yield* Deferred.fail( + context.firstConnection, + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "event.subscribe", + detail: "OpenCode session stopped before the event stream connected.", + }), + ).pipe(Effect.ignore); + yield* cancelPendingOpenCodePrompt(context); + const cancellation = context.cancellation; + context.cancellation = undefined; + if (cancellation) { + yield* Deferred.succeed(cancellation.completion, undefined).pipe(Effect.ignore); + } + context.promptAdmission = undefined; // Best-effort remote abort. The scope close below tears down the local // handles (event-pump fiber, server-exit fiber, event-subscribe fetch), // but we still want to tell OpenCode that this session is done. - yield* runOpenCodeSdk("session.abort", () => - context.client.session.abort({ sessionID: context.openCodeSessionId }), - ).pipe(Effect.ignore({ log: true })); + yield* abortOpenCodeSessionForTeardown(context); // Closing the session scope interrupts every fiber forked into it and // runs each finalizer we registered — the `AbortController.abort()` call, @@ -603,6 +886,24 @@ export function makeOpenCodeAdapter( options?.nativeEventLogger === undefined ? nativeEventLogger : undefined; const runtimeEvents = yield* Queue.unbounded(); const sessions = new Map(); + const deleteContextIfCurrent = (context: OpenCodeSessionContext) => { + if (sessions.get(context.session.threadId) === context) { + sessions.delete(context.session.threadId); + } + }; + const awaitOpenCodeContextReady = Effect.fn("awaitOpenCodeContextReady")(function* ( + context: OpenCodeSessionContext, + ) { + yield* Deferred.await(context.firstConnection); + const current = yield* ensureSessionContext(sessions, context.session.threadId); + if (current !== context) { + return yield* new ProviderAdapterSessionClosedError({ + provider: PROVIDER, + threadId: context.session.threadId, + }); + } + return current; + }); const randomUUIDv4 = crypto.randomUUIDv4.pipe( Effect.mapError( (cause) => @@ -614,6 +915,37 @@ export function makeOpenCodeAdapter( }), ), ); + let messageIdEpochMillis = -1; + let messageIdCounter = 0; + // T3 supplies the message ID to match prompt admission events. Keep OpenCode's sortable native shape so equal-time messages retain their upstream order. + const makeOpenCodeMessageId = Effect.fn("makeOpenCodeMessageId")(function* () { + const epochMillis = DateTime.toEpochMillis(yield* DateTime.now); + if (epochMillis !== messageIdEpochMillis) { + messageIdEpochMillis = epochMillis; + messageIdCounter = 0; + } + messageIdCounter += 1; + const encodedTime = BigInt.asUintN( + 48, + BigInt(epochMillis) * 0x1000n + BigInt(messageIdCounter), + ) + .toString(16) + .padStart(12, "0"); + const randomBytes = yield* crypto.randomBytes(14).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "crypto/randomBytes", + detail: "Failed to generate an OpenCode message identifier.", + cause, + }), + ), + ); + const alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; + const random = Array.from(randomBytes, (byte) => alphabet[byte % alphabet.length]).join(""); + return `msg_${encodedTime}${random}`; + }); const buildEventBase = (input: EventBaseInput) => Effect.all({ eventId: randomUUIDv4.pipe(Effect.map(EventId.make)), @@ -683,6 +1015,424 @@ export function makeOpenCodeAdapter( }, ) => writeNativeEvent(threadId, event).pipe(Effect.catchCause(() => Effect.void)); + const cancelIdleReconciliation = Effect.fn("cancelIdleReconciliation")(function* ( + context: OpenCodeSessionContext, + ) { + const pending = context.pendingIdleReconciliation; + context.pendingIdleReconciliation = undefined; + if (pending?.fiber) { + yield* Fiber.interrupt(pending.fiber); + } + }); + + const completeOpenCodeTurn = Effect.fn("completeOpenCodeTurn")(function* ( + context: OpenCodeSessionContext, + turnId: TurnId, + promptGeneration: number, + raw: unknown, + ) { + const updatedAt = yield* nowIso; + const stopped = yield* Ref.get(context.stopped); + if ( + stopped || + context.activeTurnId !== turnId || + context.promptGeneration !== promptGeneration || + context.cancellation?.turnId === turnId + ) { + return; + } + const pendingIdleReconciliation = context.pendingIdleReconciliation; + if ( + pendingIdleReconciliation?.turnId === turnId && + pendingIdleReconciliation.promptGeneration === promptGeneration + ) { + context.pendingIdleReconciliation = undefined; + } + context.activeTurnId = undefined; + context.activeAgent = undefined; + context.activeVariant = undefined; + context.interruptedTurnId = undefined; + context.awaitingBusyAfterInterruption = false; + context.reconcileIdleStatus = false; + applyProviderSessionUpdate( + context, + { status: "ready" }, + { clearActiveTurnId: true }, + updatedAt, + ); + if (pendingIdleReconciliation?.fiber) { + yield* Fiber.interrupt(pendingIdleReconciliation.fiber); + } + yield* emit({ + ...(yield* buildEventBase({ + threadId: context.session.threadId, + turnId, + raw, + })), + type: "turn.completed", + payload: { + state: "completed", + }, + }); + }); + + const scheduleIdleReconciliation = Effect.fn("scheduleIdleReconciliation")(function* ( + context: OpenCodeSessionContext, + turnId: TurnId, + raw: unknown, + ) { + const existing = context.pendingIdleReconciliation; + if (existing?.turnId === turnId && existing.promptGeneration === context.promptGeneration) { + existing.raw = raw; + existing.dirty = true; + return; + } + yield* cancelIdleReconciliation(context); + + const pending: OpenCodeIdleReconciliation = { + turnId, + promptGeneration: context.promptGeneration, + raw, + warned: false, + dirty: false, + }; + context.pendingIdleReconciliation = pending; + const reconcile = Effect.gen(function* () { + let retryCount = 0; + while (context.pendingIdleReconciliation === pending) { + if ( + context.activeTurnId !== turnId || + context.awaitingBusyAfterInterruption || + context.promptGeneration !== pending.promptGeneration + ) { + context.pendingIdleReconciliation = undefined; + return; + } + const result = yield* runOpenCodeSdk("session.status", (signal) => + context.client.session.status(undefined, { signal }), + ).pipe( + Effect.timeout("1 second"), + Effect.retry({ times: 1 }), + Effect.match({ + onFailure: (cause) => ({ type: "unknown" as const, cause }), + onSuccess: (response) => { + const data = Option.getOrUndefined(decodeOpenCodeSessionStatusMap(response.data)); + if (data === undefined) { + return { type: "unknown" as const, cause: undefined }; + } + const status = data[context.openCodeSessionId]; + if (status === undefined || status.type === "idle") { + return { type: "idle" as const }; + } + if (status.type === "busy" || status.type === "retry") { + return { type: "busy" as const }; + } + return { type: "unknown" as const, cause: undefined }; + }, + }), + ); + + if ( + context.pendingIdleReconciliation !== pending || + context.activeTurnId !== turnId || + context.promptGeneration !== pending.promptGeneration + ) { + return; + } + if (result.type === "idle") { + context.pendingIdleReconciliation = undefined; + yield* completeOpenCodeTurn(context, turnId, pending.promptGeneration, pending.raw); + return; + } + if (result.type === "busy") { + if (pending.dirty) { + pending.dirty = false; + continue; + } + context.pendingIdleReconciliation = undefined; + return; + } + if (!pending.warned) { + pending.warned = true; + yield* emit({ + ...(yield* buildEventBase({ threadId: context.session.threadId, turnId })), + type: "runtime.warning", + payload: { + message: "OpenCode turn completion is waiting for session status.", + detail: + result.cause === undefined + ? "session.status returned missing or invalid status data." + : openCodeRuntimeErrorDetail(result.cause), + }, + }); + } + const delayMs = Math.min(250 * 2 ** retryCount, 5_000); + retryCount += 1; + yield* Effect.sleep(`${delayMs} millis`); + } + }).pipe( + Effect.catchCause(() => Effect.void), + Effect.ensuring( + Effect.sync(() => { + if (context.pendingIdleReconciliation === pending) { + context.pendingIdleReconciliation = undefined; + } + }), + ), + ); + pending.fiber = yield* reconcile.pipe(Effect.forkIn(context.sessionScope)); + }); + + const failPromptAdmissionRecovery = Effect.fn("failPromptAdmissionRecovery")(function* ( + context: OpenCodeSessionContext, + promptAdmission: OpenCodePromptAdmission, + ) { + if ( + context.promptAdmission !== promptAdmission || + context.activeTurnId !== promptAdmission.turnId || + context.promptGeneration !== promptAdmission.generation + ) { + return; + } + const detail = + "OpenCode accepted the prompt, but T3 Code could not confirm its message or session status."; + const abortExit = yield* Effect.exit( + runOpenCodeSdk("session.abort", (signal) => + context.client.session.abort({ sessionID: context.openCodeSessionId }, { signal }), + ).pipe(Effect.timeout("1 second")), + ); + if (Exit.isFailure(abortExit)) { + yield* emitUnexpectedExit( + context, + `${detail} The cleanup abort also failed: ${openCodeRuntimeErrorDetail(Cause.squash(abortExit.cause))}`, + ); + deleteContextIfCurrent(context); + return; + } + context.promptAdmission = undefined; + context.activeTurnId = undefined; + context.activeAgent = undefined; + context.activeVariant = undefined; + context.awaitingBusyAfterInterruption = false; + context.reconcileIdleStatus = false; + yield* updateProviderSession( + context, + { status: "error", lastError: detail }, + { clearActiveTurnId: true }, + ); + yield* emit({ + ...(yield* buildEventBase({ + threadId: context.session.threadId, + turnId: promptAdmission.turnId, + raw: promptAdmission.recoveryRaw, + })), + type: "turn.completed", + payload: { + state: "failed", + errorMessage: detail, + }, + }); + yield* emit({ + ...(yield* buildEventBase({ + threadId: context.session.threadId, + turnId: promptAdmission.turnId, + raw: promptAdmission.recoveryRaw, + })), + type: "runtime.error", + payload: { + message: detail, + class: "transport_error", + }, + }); + }); + + const schedulePromptAdmissionRecovery = Effect.fn("schedulePromptAdmissionRecovery")(function* ( + context: OpenCodeSessionContext, + raw: unknown, + ) { + const promptAdmission = context.promptAdmission; + if (!promptAdmission || promptAdmission.cancelled) { + return; + } + if (raw !== undefined) { + promptAdmission.recoveryRaw = raw; + } + if (promptAdmission.recoveryFiber) { + return; + } + const recover = Effect.gen(function* () { + yield* Deferred.await(promptAdmission.acceptance); + for (let retryCount = 0; retryCount < 5; retryCount += 1) { + if ( + context.promptAdmission !== promptAdmission || + context.activeTurnId !== promptAdmission.turnId || + context.promptGeneration !== promptAdmission.generation || + promptAdmission.cancelled || + (yield* Ref.get(context.stopped)) + ) { + return; + } + + if (!promptAdmission.messageObserved) { + const response = yield* runOpenCodeSdk("session.message", (signal) => + context.client.session.message( + { + sessionID: context.openCodeSessionId, + messageID: promptAdmission.messageId, + }, + { signal }, + ), + ).pipe(Effect.timeout("1 second"), Effect.option); + const stopped = yield* Ref.get(context.stopped); + if ( + stopped || + sessions.get(context.session.threadId) !== context || + context.promptAdmission !== promptAdmission || + context.activeTurnId !== promptAdmission.turnId || + context.promptGeneration !== promptAdmission.generation || + promptAdmission.cancelled + ) { + return; + } + const message = Option.isSome(response) ? response.value.data : undefined; + if (message?.info.id === promptAdmission.messageId && message.info.role === "user") { + promptAdmission.messageObserved = true; + context.messageRoleById.set(promptAdmission.messageId, "user"); + } + } + + const statusResponse = yield* runOpenCodeSdk("session.status", (signal) => + context.client.session.status(undefined, { signal }), + ).pipe(Effect.timeout("1 second"), Effect.option); + const stopped = yield* Ref.get(context.stopped); + if ( + stopped || + sessions.get(context.session.threadId) !== context || + context.promptAdmission !== promptAdmission || + context.activeTurnId !== promptAdmission.turnId || + context.promptGeneration !== promptAdmission.generation || + promptAdmission.cancelled + ) { + return; + } + const statusData = Option.isSome(statusResponse) + ? Option.getOrUndefined(decodeOpenCodeSessionStatusMap(statusResponse.value.data)) + : undefined; + const status = statusData?.[context.openCodeSessionId]; + const isIdle = + statusData !== undefined && (status === undefined || status.type === "idle"); + const isBusy = status?.type === "busy" || status?.type === "retry"; + if (isBusy) { + promptAdmission.busyObserved = true; + promptAdmission.idleStatusConfirmations = 0; + context.awaitingBusyAfterInterruption = false; + context.promptAdmission = undefined; + return; + } + + const idle = promptAdmission.idleDuringAdmission ?? promptAdmission.priorIdle; + if ( + isIdle && + idle !== undefined && + (promptAdmission.messageObserved || promptAdmission.busyObserved) + ) { + context.promptAdmission = undefined; + context.awaitingBusyAfterInterruption = false; + yield* scheduleIdleReconciliation(context, promptAdmission.turnId, idle.raw); + return; + } + if (isIdle && promptAdmission.messageObserved) { + promptAdmission.idleStatusConfirmations += 1; + if (promptAdmission.idleStatusConfirmations >= 2) { + context.promptAdmission = undefined; + context.awaitingBusyAfterInterruption = false; + yield* completeOpenCodeTurn( + context, + promptAdmission.turnId, + promptAdmission.generation, + { + type: "session.status.recovered", + status: statusData, + }, + ); + return; + } + } else if (!isIdle) { + promptAdmission.idleStatusConfirmations = 0; + } + if ( + isIdle && + promptAdmission.messageObserved && + promptAdmission.recoveryRaw !== undefined + ) { + context.promptAdmission = undefined; + context.awaitingBusyAfterInterruption = false; + yield* scheduleIdleReconciliation( + context, + promptAdmission.turnId, + promptAdmission.recoveryRaw, + ); + return; + } + + const delayMs = Math.min(250 * 2 ** retryCount, 2_000); + yield* Effect.sleep(`${delayMs} millis`); + } + yield* failPromptAdmissionRecovery(context, promptAdmission); + }).pipe( + Effect.catchCause(() => Effect.void), + Effect.ensuring( + Effect.sync(() => { + delete promptAdmission.recoveryFiber; + }), + ), + ); + promptAdmission.recoveryFiber = yield* recover.pipe(Effect.forkIn(context.sessionScope)); + }); + + const interruptOpenCodeTurn = Effect.fn("interruptOpenCodeTurn")(function* ( + context: OpenCodeSessionContext, + turnId: TurnId, + raw?: unknown, + ) { + if (context.interruptedTurnId === turnId) { + return; + } + yield* cancelIdleReconciliation(context); + context.interruptedTurnId = turnId; + context.reconcileIdleStatus = true; + context.awaitingBusyAfterInterruption = false; + const cancellation = + context.cancellation?.turnId === turnId ? context.cancellation : undefined; + if (cancellation) { + context.cancellation = undefined; + } + if (context.activeTurnId === turnId) { + context.activeTurnId = undefined; + context.activeAgent = undefined; + context.activeVariant = undefined; + yield* updateProviderSession( + context, + { status: "ready" }, + { clearActiveTurnId: true, clearLastError: true }, + ); + } + yield* emit({ + ...(yield* buildEventBase({ + threadId: context.session.threadId, + turnId, + raw, + })), + type: "turn.aborted", + payload: { + reason: "Interrupted by user.", + }, + }); + if (cancellation) { + yield* Deferred.succeed(cancellation.completion, undefined).pipe(Effect.ignore); + } + }); + const emitUnexpectedExit = Effect.fn("emitUnexpectedExit")(function* ( context: OpenCodeSessionContext, message: string, @@ -694,8 +1444,21 @@ export function makeOpenCodeAdapter( if (yield* Ref.getAndSet(context.stopped, true)) { return; } + yield* Deferred.fail( + context.firstConnection, + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "event.subscribe", + detail: "OpenCode session exited before the event stream connected.", + }), + ).pipe(Effect.ignore); + yield* failPendingOpenCodeCancellation( + context, + "OpenCode session exited during cancellation.", + ); + context.promptAdmission = undefined; const turnId = context.activeTurnId; - sessions.delete(context.session.threadId); + deleteContextIfCurrent(context); // Emit lifecycle events BEFORE tearing down the scope. Both call sites // run this inside a fiber forked via `Effect.forkIn(context.sessionScope)`; // closing that scope triggers the fiber-interrupt finalizer, so any @@ -726,9 +1489,7 @@ export function makeOpenCodeAdapter( // Inline the teardown that `stopOpenCodeContext` would do; we can't // delegate to it because our `getAndSet` above already flipped the // one-shot guard, so the call would no-op. - yield* runOpenCodeSdk("session.abort", () => - context.client.session.abort({ sessionID: context.openCodeSessionId }), - ).pipe(Effect.ignore({ log: true })); + yield* abortOpenCodeSessionForTeardown(context); yield* Scope.close(context.sessionScope, Exit.void); }); @@ -799,28 +1560,440 @@ export function makeOpenCodeAdapter( } }); - const handleSubscribedEvent = Effect.fn("handleSubscribedEvent")(function* ( + const isRelatedOpenCodeSession = Effect.fn("isRelatedOpenCodeSession")(function* ( context: OpenCodeSessionContext, - event: OpenCodeSubscribedEvent, + candidateSessionId: string, ) { - const payloadSessionId = openCodeEventSessionId(event); - if (payloadSessionId !== context.openCodeSessionId) { - return; + if (context.relatedSessionIds.has(candidateSessionId)) { + return true; } - const turnId = context.activeTurnId; - yield* writeNativeEventBestEffort(context.session.threadId, { - observedAt: yield* nowIso, - event: { - provider: PROVIDER, + const seen = new Set(); + const getSession = (sessionID: string) => + runOpenCodeSdk("session.get", () => context.client.session.get({ sessionID })).pipe( + Effect.catchIf( + (cause) => isOpenCodeNotFound(cause), + () => Effect.succeed(undefined), + ), + ); + let sessionId: string | undefined = candidateSessionId; + for (let depth = 0; sessionId !== undefined && depth < 32; depth += 1) { + if (context.relatedSessionIds.has(sessionId)) { + context.relatedSessionIds.add(candidateSessionId); + return true; + } + if (seen.has(sessionId)) { + return false; + } + seen.add(sessionId); + const currentSessionId: string = sessionId; + const response = yield* getSession(currentSessionId); + if (response === undefined) { + return false; + } + if (!response.data) { + return yield* new OpenCodeRuntimeError({ + operation: "session.get", + detail: `OpenCode session.get returned no session payload for '${currentSessionId}'.`, + }); + } + sessionId = response.data.parentID; + } + return false; + }); + + const emitPendingOpenCodeRequest = Effect.fn("emitPendingOpenCodeRequest")(function* ( + context: OpenCodeSessionContext, + event: OpenCodeAskedRequestEvent, + raw: unknown, + ) { + if (context.resolvedRequestIds.has(event.properties.id)) { + return; + } + if (event.type === "permission.asked") { + const request = event.properties; + if (context.pendingPermissions.has(request.id)) { + return; + } + context.pendingPermissions.set(request.id, request); + yield* emit({ + ...(yield* buildEventBase({ + threadId: context.session.threadId, + turnId: context.activeTurnId, + requestId: request.id, + raw, + })), + type: "request.opened", + payload: { + requestType: mapPermissionToRequestType(request.permission), + detail: request.patterns.length > 0 ? request.patterns.join("\n") : request.permission, + args: request.metadata, + }, + }); + return; + } + + const request = event.properties; + if (context.pendingQuestions.has(request.id)) { + return; + } + context.pendingQuestions.set(request.id, request); + yield* emit({ + ...(yield* buildEventBase({ + threadId: context.session.threadId, + turnId: context.activeTurnId, + requestId: request.id, + raw, + })), + type: "user-input.requested", + payload: { questions: normalizeQuestionRequest(request) }, + }); + }); + + const resolvePendingOpenCodeRequest = Effect.fn("resolvePendingOpenCodeRequest")(function* ( + context: OpenCodeSessionContext, + requestId: string, + ) { + context.resolvedRequestIds.add(requestId); + const retry = context.requestRelationRetries.get(requestId); + context.requestRelationRetries.delete(requestId); + if (retry?.fiber) { + yield* Fiber.interrupt(retry.fiber); + } + }); + + const emitTerminalOpenCodeRequest = Effect.fn("emitTerminalOpenCodeRequest")(function* ( + context: OpenCodeSessionContext, + event: OpenCodeTerminalRequestEvent, + ) { + const requestId = event.properties.requestID; + if (context.emittedTerminalRequestIds.has(requestId)) { + return; + } + context.emittedTerminalRequestIds.add(requestId); + if (event.type === "permission.replied") { + yield* emit({ + ...(yield* buildEventBase({ + threadId: context.session.threadId, + turnId: context.activeTurnId, + requestId, + raw: event, + })), + type: "request.resolved", + payload: { + requestType: "unknown", + decision: mapPermissionDecision(event.properties.reply), + }, + }); + return; + } + + const request = context.pendingQuestions.get(requestId); + const answers = + event.type === "question.replied" && request + ? Object.fromEntries( + request.questions.map((question, index) => [ + openCodeQuestionId(index, question), + event.properties.answers[index]?.join(", ") ?? "", + ]), + ) + : {}; + yield* emit({ + ...(yield* buildEventBase({ + threadId: context.session.threadId, + turnId: context.activeTurnId, + requestId, + raw: event, + })), + type: "user-input.resolved", + payload: { answers }, + }); + }); + + const scheduleRequestRelationRetry = Effect.fn("scheduleRequestRelationRetry")(function* ( + context: OpenCodeSessionContext, + event: OpenCodeRoutedRequestEvent, + raw: unknown = event, + ) { + const isAskedEvent = event.type === "permission.asked" || event.type === "question.asked"; + const requestId = isAskedEvent ? event.properties.id : event.properties.requestID; + if (context.requestRelationRetries.has(requestId)) { + return; + } + if (isAskedEvent && context.resolvedRequestIds.has(requestId)) { + return; + } + const retry: OpenCodeRequestRelationRetry = { warned: false }; + context.requestRelationRetries.set(requestId, retry); + const run = Effect.gen(function* () { + let retryCount = 0; + while (context.requestRelationRetries.get(requestId) === retry) { + const relation = yield* isRelatedOpenCodeSession( + context, + event.properties.sessionID, + ).pipe( + Effect.match({ + onFailure: (cause) => ({ type: "unknown" as const, cause }), + onSuccess: (related) => ({ type: "known" as const, related }), + }), + ); + if (context.requestRelationRetries.get(requestId) !== retry) { + return; + } + if (relation.type === "known") { + context.requestRelationRetries.delete(requestId); + if (relation.related) { + if (isAskedEvent) { + yield* emitPendingOpenCodeRequest(context, event, raw); + } else { + yield* emitTerminalOpenCodeRequest(context, event); + } + } + return; + } + if (!retry.warned) { + retry.warned = true; + yield* emit({ + ...(yield* buildEventBase({ + threadId: context.session.threadId, + requestId, + })), + type: "runtime.warning", + payload: { + message: "OpenCode request routing is waiting for session ancestry.", + detail: openCodeRuntimeErrorDetail(relation.cause), + }, + }); + } + const delayMs = Math.min(250 * 2 ** retryCount, 5_000); + retryCount += 1; + if (!isAskedEvent && retryCount >= 5) { + return; + } + yield* Effect.sleep(`${delayMs} millis`); + } + }).pipe( + Effect.catchCause(() => Effect.void), + Effect.ensuring( + Effect.sync(() => { + if (context.requestRelationRetries.get(requestId) === retry) { + context.requestRelationRetries.delete(requestId); + } + }), + ), + ); + retry.fiber = yield* run.pipe(Effect.forkIn(context.sessionScope)); + }); + + const schedulePendingRequestRecovery = Effect.fn("schedulePendingRequestRecovery")(function* ( + context: OpenCodeSessionContext, + ) { + if (context.pendingRequestRecovery) { + context.pendingRequestRecovery.rerun = true; + return; + } + const recovery: OpenCodePendingRequestRecovery = { warned: false, rerun: false }; + context.pendingRequestRecovery = recovery; + const run = Effect.gen(function* () { + let retryCount = 0; + while (context.pendingRequestRecovery === recovery) { + const responses = yield* Effect.all({ + permissions: runOpenCodeSdk("permission.list", () => context.client.permission.list()), + questions: runOpenCodeSdk("question.list", () => context.client.question.list()), + }).pipe( + Effect.match({ + onFailure: (cause) => ({ type: "failure" as const, cause }), + onSuccess: (value) => ({ type: "success" as const, value }), + }), + ); + if (context.pendingRequestRecovery !== recovery) { + return; + } + if (responses.type === "failure") { + if (!recovery.warned) { + recovery.warned = true; + yield* emit({ + ...(yield* buildEventBase({ threadId: context.session.threadId })), + type: "runtime.warning", + payload: { + message: "OpenCode pending request recovery failed and will retry.", + detail: openCodeRuntimeErrorDetail(responses.cause), + }, + }); + } + const delayMs = Math.min(250 * 2 ** retryCount, 5_000); + retryCount += 1; + yield* Effect.sleep(`${delayMs} millis`); + continue; + } + const permissions = responses.value.permissions.data; + const questions = responses.value.questions.data; + if (permissions === undefined || questions === undefined) { + if (!recovery.warned) { + recovery.warned = true; + yield* emit({ + ...(yield* buildEventBase({ threadId: context.session.threadId })), + type: "runtime.warning", + payload: { + message: "OpenCode pending request recovery returned no data and will retry.", + }, + }); + } + const delayMs = Math.min(250 * 2 ** retryCount, 5_000); + retryCount += 1; + yield* Effect.sleep(`${delayMs} millis`); + continue; + } + yield* Effect.forEach( + permissions, + (request) => + scheduleRequestRelationRetry( + context, + { id: `recovered:${request.id}`, type: "permission.asked", properties: request }, + { type: "permission.asked", properties: request, recovered: true }, + ), + { discard: true }, + ); + yield* Effect.forEach( + questions, + (request) => + scheduleRequestRelationRetry( + context, + { id: `recovered:${request.id}`, type: "question.asked", properties: request }, + { type: "question.asked", properties: request, recovered: true }, + ), + { discard: true }, + ); + if (recovery.rerun) { + recovery.rerun = false; + recovery.warned = false; + continue; + } + context.pendingRequestRecovery = undefined; + return; + } + }).pipe( + Effect.catchCause(() => Effect.void), + Effect.ensuring( + Effect.sync(() => { + if (context.pendingRequestRecovery === recovery) { + context.pendingRequestRecovery = undefined; + } + }), + ), + ); + yield* run.pipe(Effect.forkIn(context.sessionScope)); + }); + + const handleSubscribedEvent = Effect.fn("handleSubscribedEvent")(function* ( + context: OpenCodeSessionContext, + event: OpenCodeSubscribedEvent, + ) { + if (event.type === "server.connected") { + if ( + (yield* Ref.get(context.stopped)) || + sessions.get(context.session.threadId) !== context + ) { + return; + } + const isFirstConnection = !(yield* Deferred.isDone(context.firstConnection)); + if (isFirstConnection) { + const updatedAt = yield* nowIso; + if ( + (yield* Ref.get(context.stopped)) || + sessions.get(context.session.threadId) !== context + ) { + return; + } + applyProviderSessionUpdate(context, { status: "ready" }, undefined, updatedAt); + if (!(yield* Deferred.succeed(context.firstConnection, undefined))) { + return; + } + } + yield* schedulePendingRequestRecovery(context); + if (!isFirstConnection) { + yield* schedulePromptAdmissionRecovery(context, event); + } + return; + } + const terminalRequestId = + event.type === "permission.replied" || + event.type === "question.replied" || + event.type === "question.rejected" + ? event.properties.requestID + : undefined; + if (terminalRequestId !== undefined) { + yield* resolvePendingOpenCodeRequest(context, terminalRequestId); + } + if (event.type === "session.created" || event.type === "session.updated") { + const session = event.properties.info; + if (session.parentID && context.relatedSessionIds.has(session.parentID)) { + context.relatedSessionIds.add(session.id); + } + } else if (event.type === "session.deleted") { + context.relatedSessionIds.delete(event.properties.info.id); + } + + const payloadSessionId = openCodeEventSessionId(event); + const isParentEvent = payloadSessionId === context.openCodeSessionId; + let isKnownPendingTerminalEvent = false; + if ( + payloadSessionId !== undefined && + !context.relatedSessionIds.has(payloadSessionId) && + isOpenCodeChildRequestEvent(event) + ) { + if (event.type === "permission.asked") { + yield* scheduleRequestRelationRetry(context, event); + } else if (event.type === "question.asked") { + yield* scheduleRequestRelationRetry(context, event); + } else if ( + event.type === "permission.replied" || + event.type === "question.replied" || + event.type === "question.rejected" + ) { + const requestId = event.properties.requestID; + isKnownPendingTerminalEvent = + context.pendingPermissions.has(requestId) || context.pendingQuestions.has(requestId); + if (!isKnownPendingTerminalEvent) { + yield* scheduleRequestRelationRetry(context, event); + return; + } + } + } + const isChildRequestEvent = + payloadSessionId !== undefined && + isOpenCodeChildRequestEvent(event) && + (context.relatedSessionIds.has(payloadSessionId) || isKnownPendingTerminalEvent); + if (!isParentEvent && !isChildRequestEvent) { + return; + } + + const turnId = context.activeTurnId; + yield* writeNativeEventBestEffort(context.session.threadId, { + observedAt: yield* nowIso, + event: { + provider: PROVIDER, threadId: context.session.threadId, providerThreadId: context.openCodeSessionId, type: event.type, ...(turnId ? { turnId } : {}), + ...(!isParentEvent && payloadSessionId ? { childSessionId: payloadSessionId } : {}), payload: event, }, }); + const suppressInterruptedParentOutput = + isParentEvent && + ((context.activeTurnId === undefined && + (context.interruptedTurnId !== undefined || context.reconcileIdleStatus)) || + context.awaitingBusyAfterInterruption) && + (event.type === "message.part.delta" || + event.type === "message.part.updated" || + (event.type === "message.updated" && event.properties.info.role === "assistant")); + if (suppressInterruptedParentOutput) { + return; + } + switch (event.type) { case "session.updated": { const title = openCodeEventSessionTitle(event); @@ -843,6 +2016,24 @@ export function makeOpenCodeAdapter( } case "message.updated": { + const promptAdmission = context.promptAdmission; + if ( + event.properties.info.role === "user" && + promptAdmission?.messageId === event.properties.info.id + ) { + promptAdmission.messageObserved = true; + if (promptAdmission.accepted) { + const idle = promptAdmission.idleDuringAdmission; + context.awaitingBusyAfterInterruption = false; + context.promptAdmission = undefined; + if (promptAdmission.recoveryFiber) { + yield* Fiber.interrupt(promptAdmission.recoveryFiber); + } + if (idle) { + yield* scheduleIdleReconciliation(context, idle.turnId, idle.raw); + } + } + } context.messageRoleById.set(event.properties.info.id, event.properties.info.role); if (event.properties.info.role === "assistant") { for (const part of context.partById.values()) { @@ -956,101 +2147,44 @@ export function makeOpenCodeAdapter( } case "permission.asked": { - context.pendingPermissions.set(event.properties.id, event.properties); - yield* emit({ - ...(yield* buildEventBase({ - threadId: context.session.threadId, - turnId, - requestId: event.properties.id, - raw: event, - })), - type: "request.opened", - payload: { - requestType: mapPermissionToRequestType(event.properties.permission), - detail: - event.properties.patterns.length > 0 - ? event.properties.patterns.join("\n") - : event.properties.permission, - args: event.properties.metadata, - }, - }); + yield* emitPendingOpenCodeRequest(context, event, event); break; } case "permission.replied": { context.pendingPermissions.delete(event.properties.requestID); - yield* emit({ - ...(yield* buildEventBase({ - threadId: context.session.threadId, - turnId, - requestId: event.properties.requestID, - raw: event, - })), - type: "request.resolved", - payload: { - requestType: "unknown", - decision: mapPermissionDecision(event.properties.reply), - }, - }); + yield* emitTerminalOpenCodeRequest(context, event); break; } case "question.asked": { - context.pendingQuestions.set(event.properties.id, event.properties); - yield* emit({ - ...(yield* buildEventBase({ - threadId: context.session.threadId, - turnId, - requestId: event.properties.id, - raw: event, - })), - type: "user-input.requested", - payload: { - questions: normalizeQuestionRequest(event.properties), - }, - }); + yield* emitPendingOpenCodeRequest(context, event, event); break; } case "question.replied": { - const request = context.pendingQuestions.get(event.properties.requestID); + yield* emitTerminalOpenCodeRequest(context, event); context.pendingQuestions.delete(event.properties.requestID); - const answers = Object.fromEntries( - (request?.questions ?? []).map((question, index) => [ - openCodeQuestionId(index, question), - event.properties.answers[index]?.join(", ") ?? "", - ]), - ); - yield* emit({ - ...(yield* buildEventBase({ - threadId: context.session.threadId, - turnId, - requestId: event.properties.requestID, - raw: event, - })), - type: "user-input.resolved", - payload: { answers }, - }); break; } case "question.rejected": { context.pendingQuestions.delete(event.properties.requestID); - yield* emit({ - ...(yield* buildEventBase({ - threadId: context.session.threadId, - turnId, - requestId: event.properties.requestID, - raw: event, - })), - type: "user-input.resolved", - payload: { answers: {} }, - }); + yield* emitTerminalOpenCodeRequest(context, event); break; } case "session.status": { if (event.properties.status.type === "busy") { + if (turnId === undefined) { + break; + } + yield* cancelIdleReconciliation(context); + context.awaitingBusyAfterInterruption = false; + if (context.promptAdmission?.turnId === turnId) { + context.promptAdmission.busyObserved = true; + yield* schedulePromptAdmissionRecovery(context, event); + } yield* updateProviderSession(context, { status: "running", activeTurnId: turnId, @@ -1074,19 +2208,25 @@ export function makeOpenCodeAdapter( } if (event.properties.status.type === "idle" && turnId) { - context.activeTurnId = undefined; - yield* updateProviderSession(context, { status: "ready" }, { clearActiveTurnId: true }); - yield* emit({ - ...(yield* buildEventBase({ - threadId: context.session.threadId, - turnId, - raw: event, - })), - type: "turn.completed", - payload: { - state: "completed", - }, - }); + if (context.cancellation?.turnId === turnId) { + context.cancellation.deferredIdleEvent = event; + break; + } + if (context.promptAdmission?.turnId === turnId) { + context.promptAdmission.idleDuringAdmission = { turnId, raw: event }; + context.promptAdmission.idleObservedAfterMessage = + context.promptAdmission.messageObserved; + yield* schedulePromptAdmissionRecovery(context, event); + break; + } + if (context.awaitingBusyAfterInterruption) { + break; + } + if (context.reconcileIdleStatus) { + yield* scheduleIdleReconciliation(context, turnId, event); + break; + } + yield* completeOpenCodeTurn(context, turnId, context.promptGeneration, event); } break; } @@ -1094,7 +2234,35 @@ export function makeOpenCodeAdapter( case "session.error": { const message = sessionErrorMessage(event.properties.error); const activeTurnId = context.activeTurnId; + const cancellation = context.cancellation; + if (isOpenCodeAbortError(event.properties.error)) { + if (cancellation !== undefined && cancellation.turnId === undefined) { + cancellation.acknowledged = true; + yield* Deferred.succeed(cancellation.acknowledgment, undefined).pipe(Effect.ignore); + break; + } + if (activeTurnId !== undefined && cancellation?.turnId === activeTurnId) { + cancellation.acknowledged = true; + yield* Deferred.succeed(cancellation.acknowledgment, undefined).pipe(Effect.ignore); + break; + } + if (context.interruptedTurnId !== undefined || context.reconcileIdleStatus) { + break; + } + } + yield* cancelIdleReconciliation(context); + const terminalCancellation = + activeTurnId !== undefined && cancellation?.turnId === activeTurnId + ? cancellation + : undefined; + if (terminalCancellation) { + terminalCancellation.turnSettled = true; + terminalCancellation.acknowledged = true; + } context.activeTurnId = undefined; + context.activeAgent = undefined; + context.activeVariant = undefined; + context.reconcileIdleStatus = false; yield* updateProviderSession( context, { @@ -1129,6 +2297,11 @@ export function makeOpenCodeAdapter( detail: event.properties.error, }, }); + if (terminalCancellation) { + yield* Deferred.succeed(terminalCancellation.acknowledgment, undefined).pipe( + Effect.ignore, + ); + } break; } @@ -1210,8 +2383,11 @@ export function makeOpenCodeAdapter( const resumeSessionId = parseOpenCodeResume(input.resumeCursor)?.sessionId; const existing = sessions.get(input.threadId); if (existing) { + if (existing.session.status === "connecting" && !(yield* Ref.get(existing.stopped))) { + return (yield* awaitOpenCodeContextReady(existing)).session; + } yield* stopOpenCodeContext(existing); - sessions.delete(input.threadId); + deleteContextIfCurrent(existing); } const started = yield* Effect.gen(function* () { @@ -1223,13 +2399,15 @@ export function makeOpenCodeAdapter( // process automatically. No manual `server.close()` needed. const server = yield* openCodeRuntime.connectToOpenCodeServer({ binaryPath, + directory, serverUrl, + ...(serverPassword ? { serverPassword } : {}), ...(options?.environment ? { environment: options.environment } : {}), }); const client = openCodeRuntime.createOpenCodeSdkClient({ baseUrl: server.url, directory, - ...(server.external && serverPassword ? { serverPassword } : {}), + ...(server.serverPassword ? { serverPassword: server.serverPassword } : {}), }); const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); if (mcpSession && !server.external) { @@ -1348,29 +2526,11 @@ export function makeOpenCodeAdapter( return startedExit.value; }); - // Guard against a concurrent startSession call that may have raced - // and already inserted a session while we were awaiting async work. - const raceWinner = sessions.get(input.threadId); - if (raceWinner) { - // Another call won the race — clean up. Only abort the remote - // session if we created it here; a resumed one is shared upstream - // state the winner is now using. - if (started.created) { - yield* runOpenCodeSdk("session.abort", () => - started.client.session.abort({ - sessionID: started.openCodeSession.id, - }), - ).pipe(Effect.ignore); - } - yield* Scope.close(started.sessionScope, Exit.void).pipe(Effect.ignore); - return raceWinner.session; - } - const createdAt = yield* nowIso; const session: ProviderSession = { provider: PROVIDER, providerInstanceId: boundInstanceId, - status: "ready", + status: "connecting", runtimeMode: input.runtimeMode, cwd: directory, ...(input.modelSelection ? { model: input.modelSelection.model } : {}), @@ -1392,6 +2552,10 @@ export function makeOpenCodeAdapter( server: started.server, directory, openCodeSessionId: started.openCodeSession.id, + relatedSessionIds: new Set([started.openCodeSession.id]), + resolvedRequestIds: new Set(), + emittedTerminalRequestIds: new Set(), + requestRelationRetries: new Map(), pendingPermissions: new Map(), pendingQuestions: new Map(), partById: new Map(), @@ -1402,11 +2566,56 @@ export function makeOpenCodeAdapter( activeTurnId: undefined, activeAgent: undefined, activeVariant: undefined, + cancellation: undefined, + interruptedTurnId: undefined, + reconcileIdleStatus: false, + awaitingBusyAfterInterruption: false, + pendingIdleReconciliation: undefined, + pendingRequestRecovery: undefined, + promptGeneration: 0, + promptAdmission: undefined, + promptSemaphore: Semaphore.makeUnsafe(1), + firstConnection: Deferred.makeUnsafe(), stopped: yield* Ref.make(false), sessionScope: started.sessionScope, }; + const raceWinner = sessions.get(input.threadId); + if (raceWinner) { + // Another start published first. A newly created remote session + // belongs to this loser; a resumed session is shared upstream state. + yield* closeStartingOpenCodeContext(context, started.created); + return (yield* awaitOpenCodeContextReady(raceWinner)).session; + } sessions.set(input.threadId, context); - yield* startEventPump(context); + const cleanupStartingContext = closeStartingOpenCodeContext(context, started.created).pipe( + Effect.ensuring(Effect.sync(() => deleteContextIfCurrent(context))), + ); + const connectionExit = yield* Effect.gen(function* () { + yield* startEventPump(context); + yield* Deferred.await(context.firstConnection).pipe( + Effect.timeout("10 seconds"), + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "event.subscribe", + detail: "OpenCode event stream did not connect within 10 seconds.", + cause, + }), + ), + ); + }).pipe( + Effect.onInterrupt(() => cleanupStartingContext), + Effect.exit, + ); + if (Exit.isFailure(connectionExit)) { + yield* cleanupStartingContext; + return yield* Effect.failCause(connectionExit.cause); + } + yield* awaitOpenCodeContextReady(context); + if (!started.created) { + yield* schedulePendingRequestRecovery(context); + } yield* emit({ ...(yield* buildEventBase({ threadId: input.threadId })), @@ -1423,17 +2632,13 @@ export function makeOpenCodeAdapter( }, }); - return session; + return context.session; }, ); const sendTurn: OpenCodeAdapterShape["sendTurn"] = Effect.fn("sendTurn")(function* (input) { const context = yield* ensureSessionContext(sessions, input.threadId); - // A sendTurn while a turn is active is a steer: OpenCode queues the - // prompt into the busy session and the work continues as one turn, so - // the active turn id is reused instead of opening a new turn. - const steeringTurnId = context.activeTurnId; - const turnId = steeringTurnId ?? TurnId.make(`opencode-turn-${yield* randomUUIDv4}`); + yield* awaitOpenCodeContextReady(context); const modelSelection = input.modelSelection ?? (context.session.model @@ -1456,6 +2661,8 @@ export function makeOpenCodeAdapter( } const text = input.input?.trim(); + // OpenCode ingests images, text, and PDFs natively; formats its model + // paths reject ride only as the prompt's file path line. const fileParts = toOpenCodeFileParts({ attachments: input.attachments, resolveAttachmentPath: (attachment) => @@ -1472,107 +2679,446 @@ export function makeOpenCodeAdapter( }); } - const agent = getModelSelectionStringOptionValue(modelSelection, "agent"); - const variant = getModelSelectionStringOptionValue(modelSelection, "variant"); + return yield* context.promptSemaphore.withPermit( + Effect.gen(function* () { + const freshTurnId = TurnId.make(`opencode-turn-${yield* randomUUIDv4}`); + const messageId = yield* makeOpenCodeMessageId(); + const pendingCancellation = context.cancellation; + if (pendingCancellation) { + const cancellationResult = yield* Deferred.await(pendingCancellation.completion).pipe( + Effect.result, + ); + if ((yield* Ref.get(context.stopped)) || sessions.get(input.threadId) !== context) { + return yield* Effect.interrupt; + } + if (cancellationResult._tag === "Failure") { + return yield* cancellationResult.failure; + } + } + if (sessions.get(input.threadId) !== context || (yield* Ref.get(context.stopped))) { + return yield* Effect.interrupt; + } + // A sendTurn while a turn is active is a steer. OpenCode queues the + // prompt into the running session, so the active turn id is reused. + const steeringTurnId = context.activeTurnId; + const turnId = steeringTurnId ?? freshTurnId; + const agent = getModelSelectionStringOptionValue(modelSelection, "agent"); + const variant = getModelSelectionStringOptionValue(modelSelection, "variant"); + const pendingIdleReconciliation = context.pendingIdleReconciliation; + const priorAwaitingBusy = context.awaitingBusyAfterInterruption; + const priorIdleCandidate = pendingIdleReconciliation + ? { + turnId: pendingIdleReconciliation.turnId, + raw: pendingIdleReconciliation.raw, + } + : undefined; + context.pendingIdleReconciliation = undefined; + const promptGeneration = context.promptGeneration + 1; + const promptAdmission: OpenCodePromptAdmission = { + generation: promptGeneration, + turnId, + messageId, + priorAwaitingBusy, + priorIdle: priorIdleCandidate, + idleDuringAdmission: undefined, + idleObservedAfterMessage: false, + messageObserved: false, + busyObserved: false, + idleStatusConfirmations: 0, + accepted: false, + cancelled: false, + acceptance: Deferred.makeUnsafe(), + submissionSettled: Deferred.makeUnsafe(), + recoveryRaw: undefined, + }; + context.promptGeneration = promptGeneration; + context.promptAdmission = promptAdmission; + + context.activeTurnId = turnId; + context.activeAgent = agent ?? (input.interactionMode === "plan" ? "plan" : undefined); + context.activeVariant = variant; + if (steeringTurnId === undefined) { + context.awaitingBusyAfterInterruption = context.interruptedTurnId !== undefined; + } + if (pendingIdleReconciliation?.fiber) { + yield* Fiber.interrupt(pendingIdleReconciliation.fiber); + } + yield* updateProviderSession( + context, + { + status: "running", + activeTurnId: turnId, + model: modelSelection?.model ?? context.session.model, + }, + { clearLastError: true }, + ); - context.activeTurnId = turnId; - context.activeAgent = agent ?? (input.interactionMode === "plan" ? "plan" : undefined); - context.activeVariant = variant; - yield* updateProviderSession( - context, - { - status: "running", - activeTurnId: turnId, - model: modelSelection?.model ?? context.session.model, - }, - { clearLastError: true }, - ); + if (steeringTurnId === undefined) { + yield* emit({ + ...(yield* buildEventBase({ threadId: input.threadId, turnId })), + type: "turn.started", + payload: { + model: modelSelection?.model ?? context.session.model, + ...(variant ? { effort: variant } : {}), + }, + }); + } - if (steeringTurnId === undefined) { - yield* emit({ - ...(yield* buildEventBase({ threadId: input.threadId, turnId })), - type: "turn.started", - payload: { - model: modelSelection?.model ?? context.session.model, - ...(variant ? { effort: variant } : {}), - }, - }); - } + if (promptAdmission.cancelled || (yield* Ref.get(context.stopped))) { + yield* Deferred.succeed(promptAdmission.submissionSettled, undefined).pipe( + Effect.ignore, + ); + const cancellation = context.cancellation; + if (cancellation?.turnId === turnId) { + yield* Deferred.await(cancellation.completion).pipe(Effect.result); + } + return yield* Effect.interrupt; + } - yield* runOpenCodeSdk("session.promptAsync", () => - context.client.session.promptAsync({ - sessionID: context.openCodeSessionId, - model: parsedModel, - ...(context.activeAgent ? { agent: context.activeAgent } : {}), - ...(context.activeVariant ? { variant: context.activeVariant } : {}), - parts: [...(text ? [{ type: "text" as const, text }] : []), ...fileParts], - }), - ).pipe( - Effect.mapError(toRequestError), - // On failure of a fresh turn: clear active-turn state, flip the - // session back to ready with lastError set, emit turn.aborted, then - // let the typed error propagate. We don't need to rebuild the error - // here — `toRequestError` already produced the right shape. A failed - // steer leaves the still-running original turn untouched. - Effect.tapError((requestError) => - steeringTurnId !== undefined - ? Effect.void - : Effect.gen(function* () { - context.activeTurnId = undefined; - context.activeAgent = undefined; - context.activeVariant = undefined; - yield* updateProviderSession( - context, - { - status: "ready", - model: modelSelection?.model ?? context.session.model, - lastError: requestError.detail, - }, - { clearActiveTurnId: true }, + let promptTimedOut = false; + const promptEffect = runOpenCodeSdk("session.promptAsync", (signal) => + context.client.session.promptAsync( + { + sessionID: context.openCodeSessionId, + messageID: messageId, + model: parsedModel, + ...(context.activeAgent ? { agent: context.activeAgent } : {}), + ...(context.activeVariant ? { variant: context.activeVariant } : {}), + parts: [...(text ? [{ type: "text" as const, text }] : []), ...fileParts], + }, + { signal }, + ), + ).pipe( + Effect.timeout("10 seconds"), + Effect.catchTags({ + OpenCodeRuntimeError: (cause) => Effect.fail(toRequestError(cause)), + TimeoutError: (cause) => { + promptTimedOut = true; + return Effect.fail( + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session.promptAsync", + detail: "OpenCode prompt submission did not complete within 10 seconds.", + cause, + }), ); - yield* emit({ - ...(yield* buildEventBase({ - threadId: input.threadId, - turnId, - })), - type: "turn.aborted", - payload: { - reason: requestError.detail, - }, - }); + }, + }), + Effect.tapError((requestError) => + context.promptAdmission !== promptAdmission || context.activeTurnId !== turnId + ? Effect.void + : Effect.gen(function* () { + if (!promptTimedOut) { + if (steeringTurnId !== undefined) { + context.promptAdmission = undefined; + context.awaitingBusyAfterInterruption = promptAdmission.priorAwaitingBusy; + const idle = + promptAdmission.idleDuringAdmission ?? promptAdmission.priorIdle; + if (idle) { + yield* scheduleIdleReconciliation(context, idle.turnId, idle.raw); + } + return; + } + context.promptAdmission = undefined; + context.activeTurnId = undefined; + context.activeAgent = undefined; + context.activeVariant = undefined; + yield* updateProviderSession( + context, + { + status: "ready", + model: modelSelection?.model ?? context.session.model, + lastError: requestError.detail, + }, + { clearActiveTurnId: true }, + ); + yield* emit({ + ...(yield* buildEventBase({ threadId: input.threadId, turnId })), + type: "turn.aborted", + payload: { reason: requestError.detail }, + }); + return; + } + const cleanupExit = yield* Effect.exit( + runOpenCodeSdk("session.abort", (signal) => + context.client.session.abort( + { sessionID: context.openCodeSessionId }, + { signal }, + ), + ).pipe(Effect.timeout("1 second")), + ); + if (Exit.isFailure(cleanupExit)) { + yield* emit({ + ...(yield* buildEventBase({ threadId: input.threadId, turnId })), + type: "runtime.warning", + payload: { + message: + "OpenCode prompt submission failed and its cleanup abort did not complete.", + detail: openCodeRuntimeErrorDetail(Cause.squash(cleanupExit.cause)), + }, + }); + yield* schedulePromptAdmissionRecovery(context, { + requestError, + cleanupError: Cause.squash(cleanupExit.cause), + }); + return; + } + context.promptAdmission = undefined; + context.activeTurnId = undefined; + context.activeAgent = undefined; + context.activeVariant = undefined; + context.awaitingBusyAfterInterruption = false; + context.reconcileIdleStatus = false; + yield* updateProviderSession( + context, + { + status: "ready", + model: modelSelection?.model ?? context.session.model, + lastError: requestError.detail, + }, + { clearActiveTurnId: true }, + ); + yield* emit({ + ...(yield* buildEventBase({ + threadId: input.threadId, + turnId, + })), + type: "turn.aborted", + payload: { + reason: requestError.detail, + }, + }); + }), + ), + Effect.onExit((exit) => + Effect.gen(function* () { + yield* Deferred.succeed(promptAdmission.submissionSettled, undefined).pipe( + Effect.ignore, + ); + if (Exit.isFailure(exit)) { + yield* Deferred.succeed(promptAdmission.acceptance, undefined).pipe( + Effect.ignore, + ); + } }), - ), - ); + ), + Effect.asVoid, + ); + const promptFiber = yield* promptEffect.pipe(Effect.forkIn(context.sessionScope)); + promptAdmission.promptFiber = promptFiber; + const promptExit = yield* Effect.exit(Fiber.join(promptFiber)); + delete promptAdmission.promptFiber; + + const intentionallyCancelled = + promptAdmission.cancelled || + (yield* Ref.get(context.stopped)) || + sessions.get(input.threadId) !== context; + if (Exit.isFailure(promptExit) && !intentionallyCancelled) { + return yield* Effect.failCause(promptExit.cause); + } + const cancelled = + intentionallyCancelled || + context.activeTurnId !== turnId || + context.promptGeneration !== promptAdmission.generation; + if (cancelled) { + const cancellation = context.cancellation; + if (cancellation?.turnId === turnId) { + yield* Deferred.await(cancellation.completion).pipe(Effect.result); + } + if (context.promptAdmission === promptAdmission) { + context.promptAdmission = undefined; + } + return yield* Effect.interrupt; + } + promptAdmission.accepted = true; + yield* Deferred.succeed(promptAdmission.acceptance, undefined).pipe(Effect.ignore); + if ( + context.promptAdmission === promptAdmission && + context.activeTurnId === turnId && + context.promptGeneration === promptAdmission.generation && + promptAdmission.messageObserved + ) { + context.awaitingBusyAfterInterruption = false; + const idle = promptAdmission.idleDuringAdmission; + if (idle && !promptAdmission.idleObservedAfterMessage) { + yield* schedulePromptAdmissionRecovery(context, idle.raw); + } else { + context.promptAdmission = undefined; + } + if (idle && promptAdmission.idleObservedAfterMessage) { + yield* scheduleIdleReconciliation(context, turnId, idle.raw); + } + } else { + yield* schedulePromptAdmissionRecovery(context, promptAdmission.recoveryRaw); + } - return { - threadId: input.threadId, - turnId, - // Re-surface the durable cursor on every turn so the persisted binding - // is refreshed alongside last-seen/runtime state (mirrors Grok/Codex). - ...(context.session.resumeCursor !== undefined - ? { resumeCursor: context.session.resumeCursor } - : {}), - }; + const stopped = yield* Ref.get(context.stopped); + const finalCancellation = context.cancellation; + if ( + stopped || + sessions.get(input.threadId) !== context || + promptAdmission.cancelled || + context.activeTurnId !== turnId || + context.promptGeneration !== promptAdmission.generation || + finalCancellation?.turnId === turnId + ) { + if (finalCancellation?.turnId === turnId) { + yield* Deferred.await(finalCancellation.completion).pipe(Effect.result); + } + if (context.promptAdmission === promptAdmission) { + context.promptAdmission = undefined; + } + return yield* Effect.interrupt; + } + + return { + threadId: input.threadId, + turnId, + // Re-surface the durable cursor on every turn so the persisted binding + // is refreshed alongside last-seen/runtime state (mirrors Grok/Codex). + ...(context.session.resumeCursor !== undefined + ? { resumeCursor: context.session.resumeCursor } + : {}), + }; + }), + ); }); const interruptTurn: OpenCodeAdapterShape["interruptTurn"] = Effect.fn("interruptTurn")( function* (threadId, turnId) { const context = yield* ensureSessionContext(sessions, threadId); - yield* runOpenCodeSdk("session.abort", () => - context.client.session.abort({ sessionID: context.openCodeSessionId }), - ).pipe(Effect.mapError(toRequestError)); - if (turnId ?? context.activeTurnId) { - yield* emit({ - ...(yield* buildEventBase({ - threadId, - turnId: turnId ?? context.activeTurnId, - })), - type: "turn.aborted", - payload: { - reason: "Interrupted by user.", - }, - }); + const activeTurnId = context.activeTurnId; + if (turnId !== undefined && activeTurnId !== turnId) { + return; + } + const interruptedTurnId = turnId ?? activeTurnId; + yield* cancelIdleReconciliation(context); + if (interruptedTurnId && context.interruptedTurnId === interruptedTurnId) { + return; + } + const existingCancellation = context.cancellation; + if (existingCancellation !== undefined) { + return yield* Deferred.await(existingCancellation.completion); + } + const cancellation: OpenCodeCancellation = { + turnId: interruptedTurnId, + acknowledgment: Deferred.makeUnsafe(), + completion: Deferred.makeUnsafe(), + }; + context.cancellation = cancellation; + const promptAdmission = context.promptAdmission; + if (promptAdmission !== undefined && promptAdmission.turnId === interruptedTurnId) { + promptAdmission.cancelled = true; + if (promptAdmission.promptFiber) { + yield* Fiber.interrupt(promptAdmission.promptFiber); + } + yield* Deferred.await(promptAdmission.submissionSettled); + } + + const parentAbortOutcome = yield* Effect.raceFirst( + runOpenCodeSdk("session.abort", (signal) => + context.client.session.abort({ sessionID: context.openCodeSessionId }, { signal }), + ).pipe( + Effect.asVoid, + Effect.timeout("10 seconds"), + Effect.catchTags({ + OpenCodeRuntimeError: (cause) => Effect.fail(toRequestError(cause)), + TimeoutError: (cause) => + Effect.fail( + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session.abort", + detail: "OpenCode session abort did not complete within 10 seconds.", + cause, + }), + ), + }), + Effect.exit, + Effect.map((exit) => ({ source: "request" as const, exit })), + ), + Effect.raceFirst( + Deferred.await(cancellation.acknowledgment).pipe( + Effect.map(() => ({ source: "acknowledgment" as const })), + ), + Deferred.await(cancellation.completion).pipe( + Effect.exit, + Effect.map((exit) => ({ source: "completion" as const, exit })), + ), + ), + ); + if (parentAbortOutcome.source === "completion") { + return Exit.isFailure(parentAbortOutcome.exit) + ? yield* Effect.failCause(parentAbortOutcome.exit.cause) + : undefined; + } + const parentAbortExit = + parentAbortOutcome.source === "request" ? parentAbortOutcome.exit : Exit.void; + + const descendantAbortOutcome = yield* Effect.raceFirst( + abortOpenCodeDescendants(context).pipe( + Effect.timeout("10 seconds"), + Effect.catchTags({ + OpenCodeRuntimeError: (cause) => Effect.fail(toRequestError(cause)), + TimeoutError: (cause) => + Effect.fail( + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session.abort", + detail: "OpenCode child session cleanup did not complete within 10 seconds.", + cause, + }), + ), + }), + Effect.exit, + Effect.map((exit) => ({ source: "request" as const, exit })), + ), + Deferred.await(cancellation.completion).pipe( + Effect.exit, + Effect.map((exit) => ({ source: "completion" as const, exit })), + ), + ); + if (descendantAbortOutcome.source === "completion") { + return Exit.isFailure(descendantAbortOutcome.exit) + ? yield* Effect.failCause(descendantAbortOutcome.exit.cause) + : undefined; + } + + const parentAbortFailed = Exit.isFailure(parentAbortExit) && !cancellation.acknowledged; + const failedExit = parentAbortFailed + ? parentAbortExit + : Exit.isFailure(descendantAbortOutcome.exit) + ? descendantAbortOutcome.exit + : undefined; + if (failedExit !== undefined && Exit.isFailure(failedExit)) { + if (context.cancellation === cancellation) { + context.cancellation = undefined; + if ( + parentAbortFailed && + cancellation.turnId !== undefined && + cancellation.deferredIdleEvent + ) { + yield* scheduleIdleReconciliation( + context, + cancellation.turnId, + cancellation.deferredIdleEvent, + ); + } + } + yield* Deferred.done(cancellation.completion, failedExit).pipe(Effect.ignore); + return yield* Effect.failCause(failedExit.cause); + } + + if (context.cancellation === cancellation) { + if (cancellation.turnSettled) { + context.cancellation = undefined; + } else if (cancellation.turnId !== undefined) { + yield* interruptOpenCodeTurn(context, cancellation.turnId); + } else { + context.cancellation = undefined; + context.reconcileIdleStatus = true; + } } + yield* Deferred.succeed(cancellation.completion, undefined).pipe(Effect.ignore); }, ); @@ -1627,7 +3173,7 @@ export function makeOpenCodeAdapter( }); } const stopped = yield* stopOpenCodeContext(context); - sessions.delete(threadId); + deleteContextIfCurrent(context); if (!stopped) { return; } diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts index 7c07fe5ad4b8..ed84fab9979a 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts @@ -12,8 +12,10 @@ import { ServerConfig } from "../../config.ts"; import { OpenCodeRuntime, OpenCodeRuntimeError, + resolveOpenCodeServerPassword, type OpenCodeRuntimeShape, } from "../opencodeRuntime.ts"; +import * as OpenCodeServerOwner from "../OpenCodeServerOwner.ts"; import { checkOpenCodeProviderStatus } from "./OpenCodeProvider.ts"; import type { OpenCodeInventory } from "../opencodeRuntime.ts"; const decodeOpenCodeSettings = Schema.decodeSync(OpenCodeSettings); @@ -34,8 +36,14 @@ const runtimeMock = { runVersionError: null as Error | null, versionStdout: DEFAULT_VERSION_STDOUT, inventoryError: null as Error | null, + connectionError: null as Error | null, inventoryCwd: null as string | null, closeCalls: 0, + sdkClientInputs: [] as Array<{ + baseUrl: string; + directory: string; + serverPassword?: string; + }>, inventory: { providerList: { connected: [] as string[], all: [] as unknown[], default: {} }, agents: [] as unknown[], @@ -46,8 +54,10 @@ const runtimeMock = { this.state.runVersionError = null; this.state.versionStdout = DEFAULT_VERSION_STDOUT; this.state.inventoryError = null; + this.state.connectionError = null; this.state.inventoryCwd = null; this.state.closeCalls = 0; + this.state.sdkClientInputs.length = 0; this.state.inventory = { providerList: { connected: [], all: [] as unknown[], default: {} }, agents: [] as unknown[], @@ -57,13 +67,37 @@ const runtimeMock = { }; const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { - startOpenCodeServerProcess: () => - Effect.succeed({ - url: "http://127.0.0.1:4301", - exitCode: Effect.never, + startOpenCodeServerProcess: ({ serverPassword, environment }) => + Effect.gen(function* () { + yield* Effect.addFinalizer(() => + Effect.sync(() => { + runtimeMock.state.closeCalls += 1; + }), + ); + const effectiveServerPassword = resolveOpenCodeServerPassword({ + external: false, + ...(serverPassword !== undefined ? { serverPassword } : {}), + ...(environment !== undefined ? { environment } : {}), + }); + return { + url: "http://127.0.0.1:4301", + ...(effectiveServerPassword !== undefined + ? { serverPassword: effectiveServerPassword } + : {}), + version: "1.14.19", + isRunning: Effect.succeed(true), + exitCode: Effect.never, + }; }), - connectToOpenCodeServer: ({ serverUrl }) => + connectToOpenCodeServer: ({ serverUrl, serverPassword }) => Effect.gen(function* () { + if (runtimeMock.state.connectionError) { + return yield* new OpenCodeRuntimeError({ + operation: "global.health", + detail: runtimeMock.state.connectionError.message, + cause: runtimeMock.state.connectionError, + }); + } if (!serverUrl) { yield* Effect.addFinalizer(() => Effect.sync(() => { @@ -73,6 +107,8 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { } return { url: serverUrl ?? "http://127.0.0.1:4301", + ...(serverPassword ? { serverPassword } : {}), + version: "1.14.19", exitCode: null, external: Boolean(serverUrl), }; @@ -87,8 +123,10 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { }), ) : Effect.succeed({ stdout: runtimeMock.state.versionStdout, stderr: "", code: 0 }), - createOpenCodeSdkClient: () => - ({}) as unknown as ReturnType, + createOpenCodeSdkClient: (input) => { + runtimeMock.state.sdkClientInputs.push(input); + return {} as unknown as ReturnType; + }, loadOpenCodeInventory: () => runtimeMock.state.inventoryError ? Effect.fail( @@ -132,11 +170,31 @@ const makeOpenCodeSettings = (overrides?: Partial): OpenCodeSe ...overrides, }); +const checkProvider = Effect.fn("checkProvider")(function* ( + settings: OpenCodeSettings, + cwd = process.cwd(), + environment?: NodeJS.ProcessEnv, +) { + return yield* Effect.scoped( + Effect.gen(function* () { + const serverOwner = yield* OpenCodeServerOwner.make({ + binaryPath: settings.binaryPath, + directory: cwd, + ...(settings.serverPassword ? { serverPassword: settings.serverPassword } : {}), + ...(environment ? { environment } : {}), + }); + return yield* checkOpenCodeProviderStatus(settings, cwd, environment).pipe( + Effect.provideService(OpenCodeServerOwner.OpenCodeServerOwner, serverOwner), + ); + }), + ); +}); + it.layer(testLayer)("checkOpenCodeProviderStatus", (it) => { it.effect("shows a codex-style missing binary message", () => Effect.gen(function* () { runtimeMock.state.runVersionError = new Error("spawn opencode ENOENT"); - const snapshot = yield* checkOpenCodeProviderStatus(makeOpenCodeSettings(), process.cwd()); + const snapshot = yield* checkProvider(makeOpenCodeSettings()); NodeAssert.equal(snapshot.status, "error"); NodeAssert.equal(snapshot.installed, false); @@ -150,7 +208,7 @@ it.layer(testLayer)("checkOpenCodeProviderStatus", (it) => { it.effect("hides generic Effect.tryPromise text for local CLI probe failures", () => Effect.gen(function* () { runtimeMock.state.runVersionError = new Error("An error occurred in Effect.tryPromise"); - const snapshot = yield* checkOpenCodeProviderStatus(makeOpenCodeSettings(), process.cwd()); + const snapshot = yield* checkProvider(makeOpenCodeSettings()); NodeAssert.equal(snapshot.status, "error"); NodeAssert.equal(snapshot.installed, true); @@ -190,7 +248,7 @@ it.layer(testLayer)("checkOpenCodeProviderStatus", (it) => { ], }; - const snapshot = yield* checkOpenCodeProviderStatus(makeOpenCodeSettings(), process.cwd()); + const snapshot = yield* checkProvider(makeOpenCodeSettings()); const model = snapshot.models.find((entry) => entry.slug === "openai/gpt-5.4"); NodeAssert.ok(model); @@ -253,7 +311,7 @@ it.layer(testLayer)("checkOpenCodeProviderStatus", (it) => { ], }; - const snapshot = yield* checkOpenCodeProviderStatus(makeOpenCodeSettings(), process.cwd()); + const snapshot = yield* checkProvider(makeOpenCodeSettings()); NodeAssert.deepEqual( snapshot.skills.map((skill) => ({ @@ -280,41 +338,109 @@ it.layer(testLayer)("checkOpenCodeProviderStatus", (it) => { }), ); - it.effect("does not spawn a local server for health check (uses CLI instead)", () => + it.effect("loads local inventory from a scoped OpenCode server", () => + Effect.gen(function* () { + yield* checkProvider(makeOpenCodeSettings({ serverPassword: "secret-password" })); + + NodeAssert.deepEqual(runtimeMock.state.sdkClientInputs, [ + { + baseUrl: "http://127.0.0.1:4301", + directory: process.cwd(), + serverPassword: "secret-password", + }, + ]); + NodeAssert.equal(runtimeMock.state.closeCalls, 1); + NodeAssert.equal(runtimeMock.state.inventoryCwd, null); + }), + ); + + it.effect("uses an environment-only password for local inventory", () => Effect.gen(function* () { - yield* checkOpenCodeProviderStatus(makeOpenCodeSettings(), process.cwd()); + yield* checkProvider(makeOpenCodeSettings(), process.cwd(), { + OPENCODE_SERVER_PASSWORD: "environment-password", + }); - NodeAssert.equal(runtimeMock.state.closeCalls, 0); - NodeAssert.equal(runtimeMock.state.inventoryCwd, process.cwd()); + NodeAssert.deepEqual(runtimeMock.state.sdkClientInputs, [ + { + baseUrl: "http://127.0.0.1:4301", + directory: process.cwd(), + serverPassword: "environment-password", + }, + ]); + }), + ); + + it.effect("uses the settings password when local environment auth differs", () => + Effect.gen(function* () { + yield* checkProvider( + makeOpenCodeSettings({ serverPassword: "settings-password" }), + process.cwd(), + { OPENCODE_SERVER_PASSWORD: "environment-password" }, + ); + + NodeAssert.equal(runtimeMock.state.sdkClientInputs[0]?.serverPassword, "settings-password"); }), ); it.effect("reports local model inventory failures without treating them as empty", () => Effect.gen(function* () { runtimeMock.state.inventoryError = new Error("opencode models failed"); - const snapshot = yield* checkOpenCodeProviderStatus(makeOpenCodeSettings(), process.cwd()); + const snapshot = yield* checkProvider(makeOpenCodeSettings()); NodeAssert.equal(snapshot.status, "error"); NodeAssert.equal(snapshot.installed, true); NodeAssert.equal(snapshot.models.length, 0); NodeAssert.equal( snapshot.message, - "Failed to execute OpenCode CLI health check: opencode models failed", + "Failed to load OpenCode provider inventory: opencode models failed", ); }), ); }); it.layer(testLayer)("checkOpenCodeProviderStatus with configured server URL", (it) => { + it.effect("does not send a local environment password to a configured server", () => + Effect.gen(function* () { + const snapshot = yield* checkProvider( + makeOpenCodeSettings({ serverUrl: "http://127.0.0.1:9999" }), + process.cwd(), + { OPENCODE_SERVER_PASSWORD: "local-secret" }, + ); + + NodeAssert.equal(snapshot.version, "1.14.19"); + NodeAssert.deepEqual(runtimeMock.state.sdkClientInputs, [ + { + baseUrl: "http://127.0.0.1:9999", + directory: process.cwd(), + }, + ]); + }), + ); + + it.effect("rejects an unsupported server before loading inventory", () => + Effect.gen(function* () { + runtimeMock.state.connectionError = new Error( + "OpenCode v1.14.18 is too old. Upgrade to v1.14.19 or newer.", + ); + const snapshot = yield* checkProvider( + makeOpenCodeSettings({ serverUrl: "http://127.0.0.1:9999" }), + ); + + NodeAssert.equal(snapshot.status, "error"); + NodeAssert.equal(snapshot.models.length, 0); + NodeAssert.match(snapshot.message ?? "", /v1\.14\.18 is too old/); + NodeAssert.equal(runtimeMock.state.sdkClientInputs.length, 0); + }), + ); + it.effect("surfaces a friendly auth error for configured servers", () => Effect.gen(function* () { - runtimeMock.state.inventoryError = new Error("401 Unauthorized"); - const snapshot = yield* checkOpenCodeProviderStatus( + runtimeMock.state.connectionError = new Error("401 Unauthorized"); + const snapshot = yield* checkProvider( makeOpenCodeSettings({ serverUrl: "http://127.0.0.1:9999", serverPassword: "secret-password", }), - process.cwd(), ); NodeAssert.equal(snapshot.status, "error"); @@ -328,15 +454,14 @@ it.layer(testLayer)("checkOpenCodeProviderStatus with configured server URL", (i it.effect("surfaces a friendly connection error for configured servers", () => Effect.gen(function* () { - runtimeMock.state.inventoryError = new Error( + runtimeMock.state.connectionError = new Error( "fetch failed: connect ECONNREFUSED 127.0.0.1:9999", ); - const snapshot = yield* checkOpenCodeProviderStatus( + const snapshot = yield* checkProvider( makeOpenCodeSettings({ serverUrl: "http://127.0.0.1:9999", serverPassword: "secret-password", }), - process.cwd(), ); NodeAssert.equal(snapshot.status, "error"); diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.ts b/apps/server/src/provider/Layers/OpenCodeProvider.ts index 62f29c47eb38..f4ce905642b6 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.ts @@ -19,17 +19,18 @@ import { type ServerProviderDraft, } from "../providerSnapshot.ts"; import { + MINIMUM_OPENCODE_VERSION, OpenCodeRuntime, openCodeRuntimeErrorDetail, type OpenCodeInventory, } from "../opencodeRuntime.ts"; import type { Agent, ProviderListResponse } from "@opencode-ai/sdk/v2"; +import * as OpenCodeServerOwner from "../OpenCodeServerOwner.ts"; const OPENCODE_PRESENTATION = { displayName: "OpenCode", showInteractionModeToggle: false, } as const; -const MINIMUM_OPENCODE_VERSION = "1.14.19"; class OpenCodeProbeError extends Data.TaggedError("OpenCodeProbeError")<{ readonly cause: unknown; @@ -65,6 +66,7 @@ function normalizedErrorMessage(cause: unknown): string | undefined { function formatOpenCodeProbeError(input: { readonly cause: unknown; readonly isExternalServer: boolean; + readonly phase: "version" | "inventory"; readonly serverUrl: string; }): { readonly installed: boolean; readonly message: string } { const detail = normalizedErrorMessage(input.cause); @@ -127,11 +129,13 @@ function formatOpenCodeProbeError(input: { }; } + const failureLabel = + input.phase === "inventory" + ? "Failed to load OpenCode provider inventory" + : "Failed to execute OpenCode CLI health check"; return { installed: true, - message: detail - ? `Failed to execute OpenCode CLI health check: ${detail}` - : "Failed to execute OpenCode CLI health check.", + message: detail ? `${failureLabel}: ${detail}` : `${failureLabel}.`, }; } @@ -326,17 +330,27 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu openCodeSettings: OpenCodeSettings, cwd: string, environment?: NodeJS.ProcessEnv, -): Effect.fn.Return { +): Effect.fn.Return< + ServerProviderDraft, + never, + OpenCodeRuntime | OpenCodeServerOwner.OpenCodeServerOwner +> { const openCodeRuntime = yield* OpenCodeRuntime; + const serverOwner = yield* OpenCodeServerOwner.OpenCodeServerOwner; const resolvedEnvironment = environment ?? process.env; const checkedAt = DateTime.formatIso(yield* DateTime.now); const customModels = openCodeSettings.customModels; const isExternalServer = openCodeSettings.serverUrl.trim().length > 0; - const fallback = (cause: unknown, version: string | null = null) => { + const fallback = ( + cause: unknown, + version: string | null = null, + phase: "version" | "inventory" = "version", + ) => { const failure = formatOpenCodeProbeError({ cause, isExternalServer, + phase, serverUrl: openCodeSettings.serverUrl, }); return buildServerProvider({ @@ -417,48 +431,52 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu } } - const inventoryExit = yield* Effect.exit( - (isExternalServer - ? Effect.scoped( - Effect.gen(function* () { - const server = yield* openCodeRuntime.connectToOpenCodeServer({ - binaryPath: openCodeSettings.binaryPath, - serverUrl: openCodeSettings.serverUrl, - environment: resolvedEnvironment, - }); - return yield* openCodeRuntime.loadOpenCodeInventory( - openCodeRuntime.createOpenCodeSdkClient({ - baseUrl: server.url, - directory: cwd, - ...(openCodeSettings.serverPassword - ? { serverPassword: openCodeSettings.serverPassword } - : {}), - }), - ); - }), - ) - : openCodeRuntime.loadInventoryFromCli({ + const loadInventory = (server: { + readonly url: string; + readonly serverPassword?: string; + readonly version: string; + }) => + openCodeRuntime + .loadOpenCodeInventory( + openCodeRuntime.createOpenCodeSdkClient({ + baseUrl: server.url, + directory: cwd, + ...(server.serverPassword !== undefined ? { serverPassword: server.serverPassword } : {}), + }), + ) + .pipe(Effect.map((inventory) => ({ inventory, version: server.version }))); + const inventoryEffect = isExternalServer + ? openCodeRuntime + .connectToOpenCodeServer({ binaryPath: openCodeSettings.binaryPath, - cwd, - environment: resolvedEnvironment, + directory: cwd, + serverUrl: openCodeSettings.serverUrl, + ...(openCodeSettings.serverPassword + ? { serverPassword: openCodeSettings.serverPassword } + : {}), }) - ).pipe( + .pipe(Effect.flatMap(loadInventory), Effect.scoped) + : serverOwner.withServer(loadInventory); + const inventoryExit = yield* Effect.exit( + inventoryEffect.pipe( Effect.mapError( (cause) => new OpenCodeProbeError({ cause, detail: openCodeRuntimeErrorDetail(cause) }), ), ), ); if (inventoryExit._tag === "Failure") { - return fallback(Cause.squash(inventoryExit.cause), version); + return fallback(Cause.squash(inventoryExit.cause), version, "inventory"); } + version = inventoryExit.value.version; + const models = providerModelsFromSettings( - flattenOpenCodeModels(inventoryExit.value), + flattenOpenCodeModels(inventoryExit.value.inventory), customModels, DEFAULT_OPENCODE_MODEL_CAPABILITIES, ); - const skills = flattenOpenCodeSkills(inventoryExit.value); - const connectedCount = inventoryExit.value.providerList.connected.length; + const skills = flattenOpenCodeSkills(inventoryExit.value.inventory); + const connectedCount = inventoryExit.value.inventory.providerList.connected.length; return buildServerProvider({ presentation: OPENCODE_PRESENTATION, enabled: true, diff --git a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts index a429367bfeb0..524b35d5d3f8 100644 --- a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts +++ b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts @@ -41,6 +41,7 @@ import * as Stream from "effect/Stream"; import { HttpClient, HttpClientResponse } from "effect/unstable/http"; import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; +import type { BuiltInDriversEnv } from "../builtInDrivers.ts"; import { ServerConfig } from "../../config.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import { ClaudeDriver } from "../Drivers/ClaudeDriver.ts"; @@ -48,6 +49,7 @@ import { CodexDriver } from "../Drivers/CodexDriver.ts"; import { CursorDriver } from "../Drivers/CursorDriver.ts"; import { GrokDriver } from "../Drivers/GrokDriver.ts"; import { OpenCodeDriver } from "../Drivers/OpenCodeDriver.ts"; +import * as ModelManifest from "../ModelManifest.ts"; import { OpenCodeRuntimeLive } from "../opencodeRuntime.ts"; import { NoOpProviderEventLoggers, ProviderEventLoggers } from "./ProviderEventLoggers.ts"; import { makeProviderInstanceRegistry } from "./ProviderInstanceRegistryLive.ts"; @@ -106,6 +108,7 @@ const makeClaudeConfig = (overrides: Partial): ClaudeSettings => homePath: "", customModels: [], launchArgs: "", + autoCompactWindow: "", ...overrides, }); @@ -147,6 +150,7 @@ describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => { Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provideMerge(TestHttpClientLive), Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), + Layer.provideMerge(ModelManifest.layerTest), ); it.live("boots two independent codex instances from a ProviderInstanceConfigMap", () => @@ -312,6 +316,7 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provideMerge(TestHttpClientLive), Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), + Layer.provideMerge(ModelManifest.layerTest), ); it.live("boots one instance of every shipped driver from a single config map", () => @@ -364,7 +369,7 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { }, }; - const { registry } = yield* makeProviderInstanceRegistry({ + const { registry } = yield* makeProviderInstanceRegistry({ drivers: [CodexDriver, ClaudeDriver, CursorDriver, GrokDriver, OpenCodeDriver], configMap, }); diff --git a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.ts b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.ts index fb75652e3856..0cc4b6c93cb8 100644 --- a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.ts +++ b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.ts @@ -411,24 +411,6 @@ export const makeProviderInstanceRegistry = (input: { return { registry, mutator }; }); -/** - * Assemble a `ProviderInstanceRegistry` Layer bound to a fixed set of - * drivers and a pre-resolved `ProviderInstanceConfigMap`. Used by tests - * that want explicit control over the registry's source-of-truth without - * wiring up the settings watcher. - * - * Only exposes the public registry tag — hot-reload consumers should use - * `ProviderInstanceRegistryMutableLayer` (below) or the hydration layer. - */ -export const ProviderInstanceRegistryLayer = (input: { - readonly drivers: ReadonlyArray>; - readonly configMap: ProviderInstanceConfigMap; -}): Layer.Layer => - Layer.effect( - ProviderInstanceRegistry, - makeProviderInstanceRegistry(input).pipe(Effect.map((built) => built.registry)), - ) as Layer.Layer; - /** * Layer variant that also exposes the mutator tag. Consumed by * `ProviderInstanceRegistryHydrationLive` to reconcile on settings diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index f7ae95d8a927..e1624a424dba 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -34,6 +34,7 @@ import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; import { checkCodexProviderStatus, type CodexAppServerProviderSnapshot } from "./CodexProvider.ts"; import { checkClaudeProviderStatus } from "./ClaudeProvider.ts"; import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; +import * as ModelManifest from "../ModelManifest.ts"; import * as OpenCodeRuntime from "../opencodeRuntime.ts"; import * as ProviderEventLoggers from "./ProviderEventLoggers.ts"; import { ProviderInstanceRegistryHydrationLive } from "./ProviderInstanceRegistryHydration.ts"; @@ -588,18 +589,35 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te }), }, ], - slashCommands: [], - skills: [], + slashCommands: [{ name: "review", description: "Review changes" }], + skills: [ + { + name: "typescript", + description: "TypeScript help", + path: "/skills/typescript/SKILL.md", + enabled: true, + }, + ], } as const satisfies ServerProvider; const refreshedProvider = { ...previousProvider, checkedAt: "2026-04-14T00:01:00.000Z", models: [], + slashCommands: [], + skills: [], } satisfies ServerProvider; assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, refreshedProvider).models, [ ...previousProvider.models, ]); + assert.deepStrictEqual( + mergeProviderSnapshot(previousProvider, refreshedProvider).slashCommands, + [], + ); + assert.deepStrictEqual( + mergeProviderSnapshot(previousProvider, refreshedProvider).skills, + [], + ); }); it("drops stale OpenCode models missing from a successful refresh", () => { @@ -669,8 +687,15 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te capabilities: null, }, ], - slashCommands: [], - skills: [], + slashCommands: [{ name: "review", description: "Review changes" }], + skills: [ + { + name: "typescript", + description: "TypeScript help", + path: "/skills/typescript/SKILL.md", + enabled: true, + }, + ], } as const satisfies ServerProvider; const refreshedProvider = { ...previousProvider, @@ -684,6 +709,14 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, refreshedProvider).models, [ ...previousProvider.models, ]); + assert.deepStrictEqual( + mergeProviderSnapshot(previousProvider, refreshedProvider).slashCommands, + previousProvider.slashCommands, + ); + assert.deepStrictEqual( + mergeProviderSnapshot(previousProvider, refreshedProvider).skills, + previousProvider.skills, + ); }); it("classifies pending, logout, uninstall, and reconnect OpenCode inventories", () => { @@ -712,8 +745,15 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te capabilities: null, }, ], - slashCommands: [], - skills: [], + slashCommands: [{ name: "review", description: "Review changes" }], + skills: [ + { + name: "typescript", + description: "TypeScript help", + path: "/skills/typescript/SKILL.md", + enabled: true, + }, + ], } as const satisfies ServerProvider; const pendingProvider = { ...previousProvider, @@ -731,6 +771,8 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te auth: { status: "unknown" }, checkedAt: "2026-07-17T00:02:00.000Z", models: [], + slashCommands: [], + skills: [], message: "OpenCode is available, but it did not report any connected upstream providers.", } satisfies ServerProvider; const missingProvider = { @@ -764,6 +806,14 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te mergeProviderSnapshot(previousProvider, loggedOutProvider).models, [], ); + assert.deepStrictEqual( + mergeProviderSnapshot(previousProvider, loggedOutProvider).slashCommands, + [], + ); + assert.deepStrictEqual( + mergeProviderSnapshot(previousProvider, loggedOutProvider).skills, + [], + ); assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, missingProvider).models, []); const afterRemoval = mergeProviderSnapshot(previousProvider, authoritativeProvider); @@ -895,6 +945,182 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te }), ); + it.effect("refreshes OpenCode catalogs and preserves other providers", () => + Effect.gen(function* () { + const codexDriver = ProviderDriverKind.make("codex"); + const openCodeDriver = ProviderDriverKind.make("opencode"); + const codexInstanceId = ProviderInstanceId.make("codex"); + const openCodeInstanceId = ProviderInstanceId.make("opencode"); + const codexRefreshCalls = yield* Ref.make(0); + const openCodeRefreshCalls = yield* Ref.make(0); + const codexProvider = { + instanceId: codexInstanceId, + driver: codexDriver, + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-06-10T00:00:00.000Z", + version: "1.0.0", + models: [], + slashCommands: [], + skills: [], + } as const satisfies ServerProvider; + const failedOpenCodeProvider = { + instanceId: openCodeInstanceId, + driver: openCodeDriver, + status: "error", + enabled: true, + installed: true, + auth: { status: "unknown" }, + checkedAt: "2026-06-10T00:00:00.000Z", + version: "1.0.0", + message: "Failed to refresh OpenCode models.", + models: [], + slashCommands: [], + skills: [], + } as const satisfies ServerProvider; + const recoveredOpenCodeProvider = { + ...failedOpenCodeProvider, + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-06-10T00:01:00.000Z", + message: "One upstream provider connected through OpenCode.", + models: [ + { + slug: "github/gpt-5", + name: "GPT-5", + subProvider: "GitHub", + isCustom: false, + capabilities: null, + }, + ], + } as const satisfies ServerProvider; + const changedCatalogProvider = { + ...recoveredOpenCodeProvider, + checkedAt: "2026-06-10T00:02:00.000Z", + models: [ + { + slug: "anthropic/claude-sonnet-4", + name: "Claude Sonnet 4", + subProvider: "Anthropic", + isCustom: false, + capabilities: null, + }, + ], + } as const satisfies ServerProvider; + const catalogSnapshot = yield* Ref.make(recoveredOpenCodeProvider); + const instances = [ + { + instanceId: codexInstanceId, + driverKind: codexDriver, + continuationIdentity: { + driverKind: codexDriver, + continuationKey: "codex:instance:codex", + }, + displayName: undefined, + enabled: true, + snapshot: { + maintenanceCapabilities: makeManualOnlyProviderMaintenanceCapabilities({ + provider: codexDriver, + packageName: null, + }), + getSnapshot: Effect.succeed(codexProvider), + refresh: Ref.update(codexRefreshCalls, (count) => count + 1).pipe( + Effect.as(codexProvider), + ), + streamChanges: Stream.empty, + }, + adapter: {} as ProviderInstance["adapter"], + textGeneration: {} as ProviderInstance["textGeneration"], + }, + { + instanceId: openCodeInstanceId, + driverKind: openCodeDriver, + continuationIdentity: { + driverKind: openCodeDriver, + continuationKey: "opencode:instance:opencode", + }, + displayName: undefined, + enabled: true, + snapshot: { + maintenanceCapabilities: makeManualOnlyProviderMaintenanceCapabilities({ + provider: openCodeDriver, + packageName: null, + }), + getSnapshot: Effect.succeed(failedOpenCodeProvider), + refresh: Ref.update(openCodeRefreshCalls, (count) => count + 1).pipe( + Effect.andThen(Ref.get(catalogSnapshot)), + ), + streamChanges: Stream.empty, + }, + adapter: {} as ProviderInstance["adapter"], + textGeneration: {} as ProviderInstance["textGeneration"], + }, + ] satisfies ReadonlyArray; + const instanceRegistryLayer = Layer.succeed( + ProviderInstanceRegistry.ProviderInstanceRegistry, + { + getInstance: (instanceId) => + Effect.succeed(instances.find((instance) => instance.instanceId === instanceId)), + listInstances: Effect.succeed(instances), + listUnavailable: Effect.succeed([]), + streamChanges: Stream.empty, + subscribeChanges: Effect.flatMap(PubSub.unbounded(), PubSub.subscribe), + }, + ); + const scope = yield* Scope.make(); + yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); + const runtimeServices = yield* Layer.build( + ProviderRegistryLive.pipe( + Layer.provideMerge(instanceRegistryLayer), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-provider-registry-reconnect-refresh-", + }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ).pipe(Scope.provide(scope)); + + yield* Effect.gen(function* () { + const registry = yield* ProviderRegistry.ProviderRegistry; + const initialProviders = yield* registry.getProviders; + assert.strictEqual( + initialProviders.find((provider) => provider.instanceId === openCodeInstanceId) + ?.status, + "error", + ); + + const recoveredProviders = yield* registry.refresh(); + assert.deepStrictEqual( + recoveredProviders.find((provider) => provider.instanceId === openCodeInstanceId) + ?.models, + recoveredOpenCodeProvider.models, + ); + assert.deepStrictEqual( + recoveredProviders.find((provider) => provider.instanceId === codexInstanceId), + codexProvider, + ); + + yield* Ref.set(catalogSnapshot, changedCatalogProvider); + const changedProviders = yield* registry.refresh(); + assert.deepStrictEqual( + changedProviders.find((provider) => provider.instanceId === openCodeInstanceId) + ?.models, + changedCatalogProvider.models, + ); + assert.deepStrictEqual( + changedProviders.find((provider) => provider.instanceId === codexInstanceId), + codexProvider, + ); + }).pipe(Effect.provide(runtimeServices)); + + assert.strictEqual(yield* Ref.get(codexRefreshCalls), 2); + assert.strictEqual(yield* Ref.get(openCodeRefreshCalls), 2); + }), + ); + it.effect("persists the merged snapshot when a live update has empty models", () => Effect.gen(function* () { const cursorDriver = ProviderDriverKind.make("cursor"); @@ -1423,6 +1649,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ProviderEventLoggers.NoOpProviderEventLoggers, ), ), + Layer.provideMerge(ModelManifest.layerTest), Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive), Layer.provideMerge(BackgroundPolicyAlwaysRunLayer), // NO spawner mock — `ChildProcessSpawner` is supplied by the @@ -1516,6 +1743,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ProviderEventLoggers.NoOpProviderEventLoggers, ), ), + Layer.provideMerge(ModelManifest.layerTest), Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive), Layer.updateService(ChildProcessSpawner.ChildProcessSpawner, (spawner) => ChildProcessSpawner.make((command) => { @@ -1638,6 +1866,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ProviderEventLoggers.NoOpProviderEventLoggers, ), ), + Layer.provideMerge(ModelManifest.layerTest), Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive), Layer.provideMerge(NodeServices.layer), Layer.provideMerge(BackgroundPolicyAlwaysRunLayer), @@ -1660,7 +1889,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ); it.effect( - "keeps cursor disabled and skips probing when the provider setting is disabled", + "keeps Cursor disabled and skips provider probing when settings use their defaults", () => Effect.gen(function* () { const serverSettings = yield* makeMutableServerSettingsService( @@ -1670,9 +1899,6 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te codex: { enabled: false, }, - cursor: { - enabled: false, - }, grok: { enabled: false, }, @@ -1700,6 +1926,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ProviderEventLoggers.NoOpProviderEventLoggers, ), ), + Layer.provideMerge(ModelManifest.layerTest), Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive), Layer.provideMerge(BackgroundPolicyAlwaysRunLayer), Layer.provideMerge( @@ -1824,192 +2051,6 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ), ); - it.effect("includes Claude Opus 5 on supported Claude Code versions", () => - Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus( - defaultClaudeSettings, - claudeCapabilities(), - ); - const opus5 = status.models.find((model) => model.slug === "claude-opus-5"); - assert.strictEqual(opus5?.name, "Claude Opus 5"); - }).pipe( - Effect.provide( - mockSpawnerLayer((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "2.1.219\n", stderr: "", code: 0 }; - if (joined === "auth status") - return { - stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', - stderr: "", - code: 0, - }; - throw new Error(`Unexpected args: ${joined}`); - }), - ), - ), - ); - - it.effect("hides Claude Opus 5 on older Claude Code versions", () => - Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus( - defaultClaudeSettings, - claudeCapabilities(), - ); - assert.strictEqual( - status.models.some((model) => model.slug === "claude-opus-5"), - false, - ); - assert.strictEqual( - status.message, - "Claude Code v2.1.218 is too old for Claude Opus 5. Upgrade to v2.1.219 or newer to access it.", - ); - }).pipe( - Effect.provide( - mockSpawnerLayer((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "2.1.218\n", stderr: "", code: 0 }; - if (joined === "auth status") - return { - stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', - stderr: "", - code: 0, - }; - throw new Error(`Unexpected args: ${joined}`); - }), - ), - ), - ); - - it.effect("includes Claude Fable 5 on supported Claude Code versions", () => - Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus( - defaultClaudeSettings, - claudeCapabilities(), - ); - const fable5 = status.models.find((model) => model.slug === "claude-fable-5"); - assert.strictEqual(fable5?.name, "Claude Fable 5"); - }).pipe( - Effect.provide( - mockSpawnerLayer((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "2.1.169\n", stderr: "", code: 0 }; - if (joined === "auth status") - return { - stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', - stderr: "", - code: 0, - }; - throw new Error(`Unexpected args: ${joined}`); - }), - ), - ), - ); - - it.effect("hides Claude Fable 5 on older Claude Code versions", () => - Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus( - defaultClaudeSettings, - claudeCapabilities(), - ); - assert.strictEqual( - status.models.some((model) => model.slug === "claude-fable-5"), - false, - ); - assert.strictEqual( - status.message, - "Claude Code v2.1.168 is too old for Claude Fable 5. Upgrade to v2.1.169 or newer to access it.", - ); - }).pipe( - Effect.provide( - mockSpawnerLayer((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "2.1.168\n", stderr: "", code: 0 }; - if (joined === "auth status") - return { - stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', - stderr: "", - code: 0, - }; - throw new Error(`Unexpected args: ${joined}`); - }), - ), - ), - ); - - it.effect( - "includes Claude Opus 4.7 with xhigh as the default effort on supported versions", - () => - Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus( - defaultClaudeSettings, - claudeCapabilities(), - ); - const opus47 = status.models.find((model) => model.slug === "claude-opus-4-7"); - if (!opus47) { - assert.fail("Expected Claude Opus 4.7 to be present for Claude Code v2.1.111."); - } - if (!opus47.capabilities) { - assert.fail( - "Expected Claude Opus 4.7 capabilities to be present for Claude Code v2.1.111.", - ); - } - const effortDescriptor = opus47.capabilities.optionDescriptors?.find( - (descriptor) => descriptor.type === "select" && descriptor.id === "effort", - ); - assert.deepStrictEqual( - effortDescriptor?.type === "select" - ? effortDescriptor.options.find((option) => option.isDefault) - : undefined, - { id: "xhigh", label: "Extra High", isDefault: true }, - ); - }).pipe( - Effect.provide( - mockSpawnerLayer((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "2.1.111\n", stderr: "", code: 0 }; - if (joined === "auth status") - return { - stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', - stderr: "", - code: 0, - }; - throw new Error(`Unexpected args: ${joined}`); - }), - ), - ), - ); - - it.effect("hides Claude Opus 4.7 on older Claude Code versions", () => - Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus( - defaultClaudeSettings, - claudeCapabilities(), - ); - assert.strictEqual( - status.models.some((model) => model.slug === "claude-opus-4-7"), - false, - ); - assert.strictEqual( - status.message, - "Claude Code v2.1.110 is too old for Claude Opus 4.7. Upgrade to v2.1.111 or newer to access it.", - ); - }).pipe( - Effect.provide( - mockSpawnerLayer((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "2.1.110\n", stderr: "", code: 0 }; - if (joined === "auth status") - return { - stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', - stderr: "", - code: 0, - }; - throw new Error(`Unexpected args: ${joined}`); - }), - ), - ), - ); - it.effect("returns a display label for claude subscription types", () => Effect.gen(function* () { const status = yield* checkClaudeProviderStatus( @@ -2154,6 +2195,10 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ); assert.deepStrictEqual(status.slashCommands, [ + { + name: "compact", + description: "Summarize the conversation and reduce context usage", + }, { name: "review", description: "Review a pull request", @@ -2197,6 +2242,10 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ); assert.deepStrictEqual(status.slashCommands, [ + { + name: "compact", + description: "Summarize the conversation and reduce context usage", + }, { name: "ui", description: "Explore and refine UI", diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts index 760c8e1c59e8..ff884b5f9006 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -95,6 +95,10 @@ const shouldRetainMissingProviderModels = (provider: ServerProvider): boolean => return isPendingInitialProbe || didInstalledProviderProbeFail; }; +const shouldRetainMissingOpenCodeMetadata = (provider: ServerProvider): boolean => + provider.driver === ProviderDriverKind.make("opencode") && + shouldRetainMissingProviderModels(provider); + const mergeProviderModels = ( provider: ServerProvider, previousModels: ReadonlyArray, @@ -132,32 +136,18 @@ export const mergeProviderSnapshot = ( : { ...nextProvider, models: mergeProviderModels(nextProvider, previousProvider.models, nextProvider.models), + ...(shouldRetainMissingOpenCodeMetadata(nextProvider) + ? { + slashCommands: + nextProvider.slashCommands.length === 0 + ? previousProvider.slashCommands + : nextProvider.slashCommands, + skills: + nextProvider.skills.length === 0 ? previousProvider.skills : nextProvider.skills, + } + : {}), }; -export const mergeProviderSnapshots = ( - previousProviders: ReadonlyArray, - nextProviders: ReadonlyArray, -): ReadonlyArray => { - const mergedProviders = new Map( - previousProviders.map((provider) => [snapshotInstanceKey(provider), provider] as const), - ); - - for (const provider of nextProviders) { - mergedProviders.set( - snapshotInstanceKey(provider), - mergeProviderSnapshot(mergedProviders.get(snapshotInstanceKey(provider)), provider), - ); - } - - return orderProviderSnapshots([...mergedProviders.values()]); -}; - -export const selectProvidersByKind = ( - providers: ReadonlyArray, - providerKinds: ReadonlySet, -): ReadonlyArray => - providers.filter((provider) => providerKinds.has(provider.driver)); - export const haveProvidersChanged = ( previousProviders: ReadonlyArray, nextProviders: ReadonlyArray, diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index bd89dc4f8812..d251d507b0c9 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -14,7 +14,6 @@ import type { } from "@t3tools/contracts"; import { ApprovalRequestId, - EnvironmentId, EventId, ProviderDriverKind, ProviderInstanceId, @@ -25,6 +24,8 @@ import { import { createModelSelection } from "@t3tools/shared/model"; import { it, assert, describe, vi } from "@effect/vitest"; +import * as Cause from "effect/Cause"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; @@ -283,7 +284,11 @@ const hasMetricSnapshot = ( Object.entries(attributes).every(([key, value]) => snapshot.attributes?.[key] === value), ); -function makeProviderServiceLayer() { +function makeProviderServiceLayer( + input: { + readonly directory?: ProviderSessionDirectory.ProviderSessionDirectory["Service"]; + } = {}, +) { const codex = makeFakeCodexAdapter(); const claude = makeFakeCodexAdapter(CLAUDE_AGENT_DRIVER); const cursor = makeFakeCodexAdapter(CURSOR_DRIVER); @@ -300,7 +305,10 @@ function makeProviderServiceLayer() { const runtimeRepositoryLayer = ProviderSessionRuntime.layer.pipe( Layer.provide(SqlitePersistenceMemory), ); - const directoryLayer = ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer)); + const directoryLayer = + input.directory === undefined + ? ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer)) + : Layer.succeed(ProviderSessionDirectory.ProviderSessionDirectory, input.directory); const layer = it.layer( Layer.mergeAll( @@ -1145,6 +1153,33 @@ routing.layer("ProviderServiceLive routing", (it) => { const imageOnlyInput = routing.codex.sendTurn.mock.calls[0]?.[0] as ProviderSendTurnInput; assert.equal(imageOnlyInput.input?.startsWith('[Attached image "screenshot.png"'), true); + const fileAttachment = { + type: "file" as const, + id: "thread-attach-12345678-1234-1234-1234-123456789abc-pdf", + name: "report.pdf", + mimeType: "application/pdf", + sizeBytes: 456, + }; + + routing.codex.sendTurn.mockClear(); + yield* provider.sendTurn({ + threadId: session.threadId, + input: "summarize the report", + attachments: [attachment, fileAttachment], + }); + const mixedInput = routing.codex.sendTurn.mock.calls[0]?.[0] as ProviderSendTurnInput; + assert.include(mixedInput.input ?? "", '[Attached file "report.pdf" is saved at: '); + assert.include(mixedInput.input ?? "", `${fileAttachment.id}.pdf]`); + // Every attachment reaches the adapter; each adapter decides what its + // provider ingests natively. + assert.deepEqual(mixedInput.attachments, [attachment, fileAttachment]); + + routing.codex.sendTurn.mockClear(); + yield* provider.sendTurn({ threadId: session.threadId, attachments: [fileAttachment] }); + const fileOnlyInput = routing.codex.sendTurn.mock.calls[0]?.[0] as ProviderSendTurnInput; + assert.include(fileOnlyInput.input ?? "", '[Attached file "report.pdf" is saved at: '); + assert.deepEqual(fileOnlyInput.attachments, [fileAttachment]); + yield* provider.stopSession({ threadId: session.threadId }); }), ); @@ -1505,6 +1540,67 @@ routing.layer("ProviderServiceLive routing", (it) => { }), ); + it.effect("does not persist running after a concurrent send is interrupted", () => + Effect.gen(function* () { + const provider = yield* ProviderService.ProviderService; + const runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; + const sendStarted = yield* Deferred.make(); + const interrupted = yield* Deferred.make(); + routing.codex.sendTurn.mockImplementationOnce(() => + Effect.gen(function* () { + yield* Deferred.succeed(sendStarted, undefined); + yield* Deferred.await(interrupted); + return yield* Effect.interrupt; + }), + ); + routing.codex.interruptTurn.mockImplementationOnce(() => + Deferred.succeed(interrupted, undefined).pipe(Effect.asVoid), + ); + + const threadId = asThreadId("thread-interrupted-send-directory"); + const session = yield* provider.startSession(threadId, { + provider: ProviderDriverKind.make("codex"), + providerInstanceId: codexInstanceId, + threadId, + runtimeMode: "full-access", + }); + const sendExitFiber = yield* provider + .sendTurn({ + threadId: session.threadId, + input: "hold this prompt", + attachments: [], + }) + .pipe(Effect.exit, Effect.forkChild); + yield* Deferred.await(sendStarted); + yield* provider.interruptTurn({ threadId: session.threadId }); + const sendExit = yield* Fiber.join(sendExitFiber); + + assert.equal(Exit.isFailure(sendExit), true); + if (Exit.isFailure(sendExit)) { + assert.equal(Cause.hasInterruptsOnly(sendExit.cause), true); + } + const persisted = yield* runtimeRepository.getByThreadId({ + threadId: session.threadId, + }); + assert.equal(Option.isSome(persisted), true); + if (Option.isSome(persisted)) { + // The directory folds both adapter "ready" and "running" into its + // runtime "running" state. The payload proves sendTurn did not upsert. + assert.equal(persisted.value.status, "running"); + const payload = persisted.value.runtimePayload; + assert.equal(payload !== null && typeof payload === "object", true); + if (payload !== null && typeof payload === "object" && !Array.isArray(payload)) { + const runtimePayload = payload as { + activeTurnId?: string | null; + lastRuntimeEvent?: string | null; + }; + assert.equal(runtimePayload.activeTurnId ?? null, null); + assert.notEqual(runtimePayload.lastRuntimeEvent, "provider.sendTurn"); + } + } + }), + ); + it.effect("reuses persisted resume cursor when startSession is called after a restart", () => Effect.gen(function* () { const tempDir = NodeFS.mkdtempSync( @@ -2119,6 +2215,53 @@ validation.layer("ProviderServiceLive validation", (it) => { ); }); +const activeSessionThreadId = asThreadId("thread-active-session"); +const historicalSessionThreadId = asThreadId("thread-historical-session"); +const listThreadIds = vi.fn(() => + Effect.succeed([activeSessionThreadId, historicalSessionThreadId]), +); +const getBinding = vi.fn((threadId: ThreadId) => + Effect.succeed( + Option.some({ + threadId, + provider: CODEX_DRIVER, + providerInstanceId: codexInstanceId, + }), + ), +); +const boundedListing = makeProviderServiceLayer({ + directory: { + upsert: () => Effect.void, + getProvider: () => Effect.die("ProviderService.listSessions does not use getProvider"), + getBinding, + listThreadIds, + listBindings: () => Effect.die("ProviderService.listSessions does not use listBindings"), + }, +}); + +boundedListing.layer("ProviderServiceLive session listing", (it) => { + it.effect("looks up bindings for active sessions without scanning historical threads", () => + Effect.gen(function* () { + const provider = yield* ProviderService.ProviderService; + yield* boundedListing.codex.startSession({ + provider: CODEX_DRIVER, + providerInstanceId: codexInstanceId, + threadId: activeSessionThreadId, + cwd: "/tmp/project-active-session", + runtimeMode: "full-access", + }); + listThreadIds.mockClear(); + getBinding.mockClear(); + + const sessions = yield* provider.listSessions(); + + assert.equal(sessions.length, 1); + assert.equal(listThreadIds.mock.calls.length, 0); + assert.deepEqual(getBinding.mock.calls, [[activeSessionThreadId]]); + }), + ); +}); + describe("agent browser access", () => { const revokedThreads: Array = []; diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index b8cd0df539ac..953dd6ca78b4 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -729,13 +729,12 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( ); } - // Adapters inline attachment pixels into the model prompt, but the model's - // tools cannot dereference pixels. Appending the on-disk path is what lets - // a turn like "include this screenshot in the PR" copy the actual file. - // This runs after schema decode, so the appended lines are exempt from the - // PROVIDER_SEND_TURN_MAX_INPUT_CHARS check; attachment count is capped, so - // the overhead is bounded. Unresolvable ids are skipped here and surface - // as adapter errors when the file is read for inlining. + // Every attachment gets an on-disk path in the prompt so the model's tools + // can dereference the actual file. All attachments then go to the adapter, + // and each adapter decides what its provider ingests natively: OpenCode + // sends generic files as file parts, the others send images only and rely + // on the path line for everything else. Unresolvable ids are skipped here + // and surface as adapter errors when the file is read. const attachmentPathLines = attachments.flatMap((attachment) => { const attachmentPath = resolveAttachmentPath({ attachmentsDir: serverConfig.attachmentsDir, @@ -757,13 +756,12 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( ...(inputTextWithAttachmentPaths !== undefined ? { input: inputTextWithAttachmentPaths } : {}), - attachments, }; yield* Effect.annotateCurrentSpan({ "provider.operation": "send-turn", "provider.thread_id": input.threadId, "provider.interaction_mode": input.interactionMode, - "provider.attachment_count": input.attachments.length, + "provider.attachment_count": attachments.length, }); let metricProvider = "unknown"; let metricModel = input.modelSelection?.model; @@ -807,7 +805,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( // often, since every toggle restarts the session. Recording it per turn // gives a usage-weighted view and lets it cross with interactionMode. runtimeMode: routed.runtimeMode, - attachmentCount: input.attachments.length, + attachmentCount: attachments.length, hasInput: typeof input.input === "string" && input.input.trim().length > 0, }); return turn; @@ -998,21 +996,21 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( ), ); const activeSessions = sessionsByProvider.flatMap((sessions) => sessions); - const persistedBindings = yield* directory.listThreadIds().pipe( - Effect.flatMap((threadIds) => - Effect.forEach( - threadIds, - (threadId) => - directory - .getBinding(threadId) - .pipe( - Effect.orElseSucceed(() => - Option.none(), - ), - ), - { concurrency: "unbounded" }, - ), - ), + // Only live adapter sessions appear in this response. Resolving every + // historical binding here makes each call scale with the full thread + // history instead of the active session set. + const persistedBindings = yield* Effect.forEach( + [...new Set(activeSessions.map((session) => session.threadId))], + (threadId) => + directory + .getBinding(threadId) + .pipe( + Effect.orElseSucceed(() => + Option.none(), + ), + ), + { concurrency: "unbounded" }, + ).pipe( Effect.orElseSucceed( () => [] as Array>, ), diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts index 23075bd9a06e..253a954d2102 100644 --- a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts +++ b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts @@ -195,7 +195,3 @@ export const ProviderSessionDirectoryLive = Layer.effect( ProviderSessionDirectory, makeProviderSessionDirectory, ); - -export function makeProviderSessionDirectoryLive() { - return Layer.effect(ProviderSessionDirectory, makeProviderSessionDirectory); -} diff --git a/apps/server/src/provider/ModelManifest.test.ts b/apps/server/src/provider/ModelManifest.test.ts new file mode 100644 index 000000000000..e46a462e438a --- /dev/null +++ b/apps/server/src/provider/ModelManifest.test.ts @@ -0,0 +1,364 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { ProviderDriverKind, type ServerProviderModel } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as TestClock from "effect/testing/TestClock"; +import { HttpClient, HttpClientResponse } from "effect/unstable/http"; + +import * as ServerConfig from "../config.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import { + BUNDLED_MODEL_MANIFEST, + classifyModels, + make, + resolveProviderCatalog, + type ModelManifestData, +} from "./ModelManifest.ts"; + +/** + * Test policy: this file covers manifest machinery, not manifest contents. + * Do not add assertions for real model slugs, names, status, aliases, or + * profiles when editing model-manifest.json. Add tests only when fetch/cache + * behavior or the provider-neutral resolver semantics change, and use + * synthetic models for resolver coverage. + */ + +const CODEX = ProviderDriverKind.make("codex"); +const model = (overrides: Partial): ServerProviderModel => ({ + slug: "gpt-test", + name: "GPT Test", + isCustom: false, + capabilities: null, + ...overrides, +}); + +describe("classifyModels", () => { + it("flags non-current models, clears stale flags, and skips custom models", () => { + const manifest: ModelManifestData = { + version: 1, + currentModels: { codex: ["current-a", "current-b"] }, + }; + const models = [ + model({ slug: "current-a" }), + // Stale flag from a previous classification pass must be cleared. + model({ slug: "current-b", isLegacy: true }), + model({ slug: "old-model" }), + // Custom models are user-defined and never reclassified. + model({ slug: "my-own-model", isCustom: true }), + ]; + assert.deepStrictEqual( + classifyModels(models, manifest, CODEX).map((entry) => [entry.slug, entry.isLegacy ?? false]), + [ + ["current-a", false], + ["current-b", false], + ["old-model", true], + ["my-own-model", false], + ], + ); + }); +}); + +describe("resolveProviderCatalog", () => { + it("resolves generic model presentation through a reusable profile", () => { + const manifest: ModelManifestData = { + version: 1, + currentModels: {}, + providers: { + synthetic: { + defaults: { chat: "model-next" }, + profiles: { + standard: { + capabilities: { + optionDescriptors: [ + { + id: "mode", + label: "Mode", + type: "select", + options: [{ id: "fast", label: "Fast", isDefault: true }], + }, + ], + }, + adapter: { opaque: true }, + }, + }, + models: [ + { + slug: "model-next", + name: "Model Next", + aliases: ["next"], + status: "current", + badge: "new", + profile: "standard", + }, + ], + }, + }, + }; + + const catalog = resolveProviderCatalog(manifest, ProviderDriverKind.make("synthetic")); + assert.deepStrictEqual(catalog?.models[0], { + model: { + slug: "model-next", + name: "Model Next", + aliases: ["next"], + badge: "new", + isCustom: false, + isDefault: true, + capabilities: manifest.providers!.synthetic!.profiles.standard!.capabilities!, + }, + adapter: undefined, + profileAdapter: { opaque: true }, + }); + }); + + it("rejects invalid catalog references", () => { + const invalidCatalog = (input: { + readonly models: NonNullable[string]["models"]; + readonly defaultChat?: string; + }): ModelManifestData => ({ + version: 1, + currentModels: {}, + providers: { + synthetic: { + ...(input.defaultChat ? { defaults: { chat: input.defaultChat } } : {}), + profiles: {}, + models: input.models, + }, + }, + }); + + for (const invalid of [ + invalidCatalog({ + models: [ + { slug: "duplicate", name: "First", status: "current" }, + { slug: "duplicate", name: "Second", status: "current" }, + ], + }), + invalidCatalog({ + models: [ + { + slug: "missing-profile", + name: "Missing Profile", + status: "current", + profile: "missing", + }, + ], + }), + invalidCatalog({ + models: [{ slug: "present", name: "Present", status: "current" }], + defaultChat: "absent", + }), + ]) { + assert.isNull(resolveProviderCatalog(invalid, ProviderDriverKind.make("synthetic"))); + } + }); +}); + +const REMOTE_MANIFEST: ModelManifestData = { + version: 1, + currentModels: { + codex: ["remote-model"], + claudeAgent: ["remote-agent-model"], + }, +}; + +const REMOTE_CLAUDE_MANIFEST: ModelManifestData = { + version: 1, + currentModels: {}, + providers: { + claudeAgent: { + profiles: { + synthetic: { + adapter: { claudeCode: { effortMap: { extreme: "high" } } }, + }, + }, + models: [ + { + slug: "remote-only-model", + name: "Remote Only Model", + status: "current", + profile: "synthetic", + }, + ], + }, + }, +}; + +const remoteClaudeManifestWithCompatibility = (compatibility: unknown): ModelManifestData => ({ + ...REMOTE_CLAUDE_MANIFEST, + providers: { + claudeAgent: { + profiles: REMOTE_CLAUDE_MANIFEST.providers!.claudeAgent!.profiles, + models: REMOTE_CLAUDE_MANIFEST.providers!.claudeAgent!.models.map((model) => ({ + ...model, + adapter: { claudeCode: compatibility }, + })), + }, + }, +}); + +const INVALID_REMOTE_MANIFESTS: ReadonlyArray = [ + { + ...REMOTE_CLAUDE_MANIFEST, + providers: { + claudeAgent: { + profiles: { + synthetic: { + adapter: { claudeCode: { effortMap: { extreme: 123 } } }, + }, + }, + models: REMOTE_CLAUDE_MANIFEST.providers!.claudeAgent!.models, + }, + }, + }, + { + ...REMOTE_CLAUDE_MANIFEST, + providers: { + claudeAgent: { + profiles: {}, + models: REMOTE_CLAUDE_MANIFEST.providers!.claudeAgent!.models, + }, + }, + }, + { + ...REMOTE_CLAUDE_MANIFEST, + providers: { + claudeAgent: { + profiles: REMOTE_CLAUDE_MANIFEST.providers!.claudeAgent!.profiles, + models: [ + ...REMOTE_CLAUDE_MANIFEST.providers!.claudeAgent!.models, + { + slug: "remote-only-model", + name: "Duplicate Remote Model", + status: "current", + profile: "synthetic", + }, + ], + }, + }, + }, + { + ...REMOTE_CLAUDE_MANIFEST, + providers: { + claudeAgent: { + defaults: { chat: "absent-model" }, + profiles: REMOTE_CLAUDE_MANIFEST.providers!.claudeAgent!.profiles, + models: REMOTE_CLAUDE_MANIFEST.providers!.claudeAgent!.models, + }, + }, + }, + remoteClaudeManifestWithCompatibility({ minVersion: "2.x" }), + remoteClaudeManifestWithCompatibility({ maxVersionExclusive: "2.x" }), + remoteClaudeManifestWithCompatibility({ + minVersion: "2.2", + maxVersionExclusive: "2.1", + }), +]; + +const httpClientLayer = (handler: () => Response) => + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => Effect.succeed(HttpClientResponse.fromWeb(request, handler()))), + ); + +const serviceLayers = (input: { + readonly prefix: string; + readonly response: () => Response; + readonly settings?: Parameters[0]; +}) => + ServerConfig.layerTest(process.cwd(), { prefix: input.prefix }).pipe( + Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(ServerSettings.layerTest(input.settings ?? {})), + Layer.provideMerge(httpClientLayer(input.response)), + ); + +describe("ModelManifest service", () => { + it.live("prefers a fetched manifest over the bundle and caches it to disk", () => + Effect.gen(function* () { + const service = yield* make; + const refreshed = yield* service.refresh; + assert.deepStrictEqual(refreshed, REMOTE_MANIFEST); + + // A fresh service instance sees the disk cache without another fetch: + // its HTTP layer is still stubbed, but `current` never fetches at all. + const rebooted = yield* make; + assert.deepStrictEqual(yield* rebooted.current, REMOTE_MANIFEST); + }).pipe( + Effect.scoped, + Effect.provide( + serviceLayers({ + prefix: "model-manifest-fetch-test", + response: () => Response.json(REMOTE_MANIFEST), + }), + ), + ), + ); + + it.live("keeps the bundled manifest when the remote payload is malformed", () => + Effect.gen(function* () { + const service = yield* make; + assert.deepStrictEqual(yield* service.refresh, BUNDLED_MODEL_MANIFEST); + }).pipe( + Effect.scoped, + Effect.provide( + serviceLayers({ + prefix: "model-manifest-malformed-test", + response: () => Response.json({ version: 999, nonsense: true }), + }), + ), + ), + ); + + it.effect("preserves the last-good remote cache when later payloads are invalid", () => { + let responseIndex = 0; + const responses = [REMOTE_CLAUDE_MANIFEST, ...INVALID_REMOTE_MANIFESTS]; + + return Effect.gen(function* () { + const service = yield* make; + assert.deepStrictEqual(yield* service.refresh, REMOTE_CLAUDE_MANIFEST); + + for (const _invalid of INVALID_REMOTE_MANIFESTS) { + yield* TestClock.adjust("1 hour"); + responseIndex += 1; + assert.deepStrictEqual(yield* service.refresh, REMOTE_CLAUDE_MANIFEST); + } + + const rebooted = yield* make; + assert.deepStrictEqual(yield* rebooted.current, REMOTE_CLAUDE_MANIFEST); + }).pipe( + Effect.scoped, + Effect.provide( + serviceLayers({ + prefix: "model-manifest-last-good-test", + response: () => Response.json(responses[responseIndex]), + }), + ), + ); + }); + + it.live("does not fetch when provider update checks are disabled", () => + Effect.gen(function* () { + let fetchCount = 0; + const service = yield* make.pipe( + Effect.provide( + httpClientLayer(() => { + fetchCount += 1; + return Response.json(REMOTE_MANIFEST); + }), + ), + ); + assert.deepStrictEqual(yield* service.refresh, BUNDLED_MODEL_MANIFEST); + assert.strictEqual(fetchCount, 0); + }).pipe( + Effect.scoped, + Effect.provide( + serviceLayers({ + prefix: "model-manifest-optout-test", + response: () => Response.json(REMOTE_MANIFEST), + settings: { enableProviderUpdateChecks: false }, + }), + ), + ), + ); +}); diff --git a/apps/server/src/provider/ModelManifest.ts b/apps/server/src/provider/ModelManifest.ts new file mode 100644 index 000000000000..2f378f835d67 --- /dev/null +++ b/apps/server/src/provider/ModelManifest.ts @@ -0,0 +1,342 @@ +/** + * ModelManifest — remote provider-model metadata with a bundled offline + * fallback. + * + * Provider catalogs and legacy classification live in `model-manifest.json`. + * The bundled copy ships with every release; at runtime the service refreshes + * it from the same file on `main`. Preference order is remote, then the last + * successful on-disk copy, then the bundle. A failed fetch never fails a + * provider check. + * + * Providers with authoritative discovery can use only the classification + * overlay. Providers with static catalogs can resolve presentation and + * capabilities from `providers`, then decode their own allowlisted adapter + * payload separately. + */ +import { + ModelCapabilities, + TrimmedNonEmptyString, + type ProviderDriverKind, + type ServerProviderModel, +} from "@t3tools/contracts"; +import * as Clock from "effect/Clock"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; +import { HttpClient, HttpClientResponse } from "effect/unstable/http"; + +import { ServerConfig } from "../config.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import { hasValidClaudeManifestAdapters } from "./ClaudeModelManifest.ts"; +import bundledManifestJson from "./model-manifest.json" with { type: "json" }; +import type { ServerProviderDraft } from "./providerSnapshot.ts"; + +const MODEL_MANIFEST_URL = + "https://raw.githubusercontent.com/pingdotgg/t3code/main/apps/server/src/provider/model-manifest.json"; + +/** How long a fetched manifest stays fresh before the next probe re-fetches. */ +const MANIFEST_TTL_MS = 60 * 60 * 1000; + +/** Minimum gap between fetch attempts after a failure, so an offline server + * does not pay a network timeout on every provider check. */ +const MANIFEST_RETRY_MS = 5 * 60 * 1000; + +const FETCH_TIMEOUT_MS = 10_000; + +const ManifestModelStatus = Schema.Literals(["current", "legacy"]); + +const ManifestModelProfile = Schema.Struct({ + capabilities: Schema.optional(ModelCapabilities), + adapter: Schema.optional(Schema.Unknown), +}); + +const ManifestProviderModel = Schema.Struct({ + slug: TrimmedNonEmptyString, + name: TrimmedNonEmptyString, + shortName: Schema.optional(TrimmedNonEmptyString), + subProvider: Schema.optional(TrimmedNonEmptyString), + aliases: Schema.optional(Schema.Array(TrimmedNonEmptyString)), + status: ManifestModelStatus, + badge: Schema.optional(Schema.Literal("new")), + profile: Schema.optional(TrimmedNonEmptyString), + adapter: Schema.optional(Schema.Unknown), +}); + +const ManifestProviderCatalog = Schema.Struct({ + defaults: Schema.optional( + Schema.Struct({ + chat: Schema.optional(TrimmedNonEmptyString), + }), + ), + profiles: Schema.Record(Schema.String, ManifestModelProfile), + models: Schema.Array(ManifestProviderModel), +}); + +/** + * `version` gates breaking schema changes. Provider catalogs are additive so + * clients that only understand `currentModels` keep accepting this v1 file. + */ +const ModelManifestEnvelopeSchema = Schema.Struct({ + version: Schema.Literal(1), + currentModels: Schema.Record(Schema.String, Schema.Array(Schema.String)), + providers: Schema.optional(Schema.Record(Schema.String, ManifestProviderCatalog)), +}); + +const hasValidProviderCatalogReferences = ( + manifest: typeof ModelManifestEnvelopeSchema.Type, +): boolean => + Object.values(manifest.providers ?? {}).every((catalog) => { + const slugs = new Set(); + const modelsAreValid = catalog.models.every((model) => { + if (slugs.has(model.slug)) return false; + slugs.add(model.slug); + return model.profile === undefined || catalog.profiles[model.profile] !== undefined; + }); + return ( + modelsAreValid && (catalog.defaults?.chat === undefined || slugs.has(catalog.defaults.chat)) + ); + }); + +const ModelManifestSchema = ModelManifestEnvelopeSchema.pipe( + Schema.check( + Schema.makeFilter(hasValidProviderCatalogReferences, { + expected: "unique model slugs and existing model and profile references", + }), + Schema.makeFilter(hasValidClaudeManifestAdapters, { + expected: "valid Claude adapter metadata", + }), + ), +); +export type ModelManifestData = typeof ModelManifestSchema.Type; + +export interface ResolvedManifestModel { + readonly model: ServerProviderModel; + readonly adapter: unknown; + readonly profileAdapter: unknown; +} + +export interface ResolvedProviderCatalog { + readonly models: ReadonlyArray; + readonly defaults: { + readonly chat: string | undefined; + }; +} + +const decodeManifest = Schema.decodeUnknownEffect(ModelManifestSchema); + +export const BUNDLED_MODEL_MANIFEST: ModelManifestData = + Schema.decodeUnknownSync(ModelManifestSchema)(bundledManifestJson); + +/** Resolve provider-neutral model presentation and capability data. */ +export function resolveProviderCatalog( + manifest: ModelManifestData, + driverKind: ProviderDriverKind, +): ResolvedProviderCatalog | null { + const catalog = manifest.providers?.[driverKind]; + if (!catalog) return null; + + const seen = new Set(); + const models: Array = []; + for (const entry of catalog.models) { + if (seen.has(entry.slug)) return null; + seen.add(entry.slug); + + const profile = entry.profile ? catalog.profiles[entry.profile] : undefined; + if (entry.profile && !profile) return null; + + models.push({ + model: { + slug: entry.slug, + name: entry.name, + ...(entry.shortName ? { shortName: entry.shortName } : {}), + ...(entry.subProvider ? { subProvider: entry.subProvider } : {}), + ...(entry.aliases ? { aliases: entry.aliases } : {}), + ...(entry.badge ? { badge: entry.badge } : {}), + isCustom: false, + ...(catalog.defaults?.chat === entry.slug ? { isDefault: true } : {}), + ...(entry.status === "legacy" ? { isLegacy: true } : {}), + capabilities: profile?.capabilities ?? null, + }, + adapter: entry.adapter, + profileAdapter: profile?.adapter, + }); + } + + if (catalog.defaults?.chat !== undefined && !seen.has(catalog.defaults.chat)) return null; + + return { + models, + defaults: { + chat: catalog.defaults?.chat, + }, + }; +} + +/** On-disk shape of the last successfully fetched manifest. */ +const ManifestCacheFile = Schema.Struct({ + fetchedAtMs: Schema.Number, + manifest: ModelManifestSchema, +}); +const decodeManifestCache = Schema.decodeUnknownEffect( + Schema.fromJsonString( + ManifestCacheFile as unknown as Schema.Codec, + ), +); +const encodeManifestCache = Schema.encodeEffect( + Schema.fromJsonString( + ManifestCacheFile as unknown as Schema.Codec, + ), +); + +/** True when the manifest classifies `slug` as legacy for `driverKind`. */ +export function isLegacyModel( + manifest: ModelManifestData, + driverKind: ProviderDriverKind, + slug: string, +): boolean { + const catalogModel = manifest.providers?.[driverKind]?.models.find( + (model) => model.slug === slug, + ); + if (catalogModel) return catalogModel.status === "legacy"; + const currentModels = manifest.currentModels[driverKind]; + if (!currentModels) return false; + return !currentModels.includes(slug); +} + +/** + * Reclassifies every built-in model on a snapshot draft against the manifest. + * Custom models are user-defined and never reclassified. + */ +export function applyModelManifest( + draft: ServerProviderDraft, + manifest: ModelManifestData, + driverKind: ProviderDriverKind, +): ServerProviderDraft { + return { ...draft, models: classifyModels(draft.models, manifest, driverKind) }; +} + +/** Model-level half of `applyModelManifest`, exported for focused tests. */ +export function classifyModels( + models: ReadonlyArray, + manifest: ModelManifestData, + driverKind: ProviderDriverKind, +): ReadonlyArray { + return models.map((model) => { + if (model.isCustom) return model; + if (isLegacyModel(manifest, driverKind, model.slug)) { + return model.isLegacy ? model : { ...model, isLegacy: true }; + } + if (!model.isLegacy) return model; + const { isLegacy: _isLegacy, ...rest } = model; + return rest; + }); +} + +export class ModelManifest extends Context.Service< + ModelManifest, + { + /** Manifest already in memory (disk cache or bundle); never fetches. + * Snapshot classification reads this, so it never waits on the network. */ + readonly current: Effect.Effect; + /** Manifest after a TTL-gated remote refresh; never fails. */ + readonly refresh: Effect.Effect; + /** Forks `refresh` into the service's own scope. Drivers call this from + * provider checks: the fetch is process-shared state, so it must survive + * the teardown of whichever instance happened to trigger it. */ + readonly refreshInBackground: Effect.Effect; + } +>()("t3/provider/ModelManifest") {} + +/** Constant service for tests and callers that only need the bundled data. */ +export const BundledOnlyModelManifest: ModelManifest["Service"] = { + current: Effect.succeed(BUNDLED_MODEL_MANIFEST), + refresh: Effect.succeed(BUNDLED_MODEL_MANIFEST), + refreshInBackground: Effect.void, +}; + +export const layerTest = Layer.succeed(ModelManifest, BundledOnlyModelManifest); + +export const make = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const config = yield* ServerConfig; + const settingsService = yield* ServerSettings.ServerSettingsService; + const httpClient = yield* HttpClient.HttpClient; + const serviceScope = yield* Effect.scope; + + const cachePath = path.join(config.stateDir, "model-manifest.json"); + let manifest = BUNDLED_MODEL_MANIFEST; + let fetchedAtMs: number | null = null; + let lastAttemptMs: number | null = null; + const refreshSemaphore = yield* Semaphore.make(1); + + // `Effect.cached` makes concurrent first readers await the same disk load + // rather than racing a "loaded" flag. Only `refreshed` takes the fetch + // semaphore; `current` must never wait behind an in-flight network refresh. + const ensureDiskCacheLoaded = yield* Effect.cached( + Effect.gen(function* () { + const fromDisk = yield* fileSystem.readFileString(cachePath).pipe( + Effect.flatMap((raw) => decodeManifestCache(raw)), + Effect.catchCause(() => Effect.succeed(null)), + ); + if (fromDisk === null) return; + // The disk copy is the last-seen remote manifest, so it outranks the + // bundle even when stale: it is refreshed on the next successful fetch. + manifest = fromDisk.manifest; + fetchedAtMs = fromDisk.fetchedAtMs; + }), + ); + + const refresh = Effect.fn("ModelManifest.refresh")(function* () { + yield* ensureDiskCacheLoaded; + const now = yield* Clock.currentTimeMillis; + // A timestamp in the future means the wall clock moved backwards (the + // disk cache crosses restarts, so monotonic time cannot cover it). Treat + // it as expired: the refetch rewrites both timestamps and self-heals. + const isWithin = (sinceMs: number | null, windowMs: number) => + sinceMs !== null && now >= sinceMs && now - sinceMs < windowMs; + if (isWithin(fetchedAtMs, MANIFEST_TTL_MS)) return manifest; + if (isWithin(lastAttemptMs, MANIFEST_RETRY_MS)) return manifest; + + // The same switch that gates provider CLI update checks. It stops network + // fetches only: a manifest already cached on disk from an earlier fetch + // stays in effect, since the setting is about phoning home, not about + // discarding data the server already holds. + const settings = yield* settingsService.getSettings.pipe( + Effect.catchCause(() => Effect.succeed(null)), + ); + if (settings !== null && !settings.enableProviderUpdateChecks) return manifest; + + lastAttemptMs = now; + const fetched = yield* httpClient.get(MODEL_MANIFEST_URL).pipe( + Effect.flatMap(HttpClientResponse.filterStatusOk), + Effect.flatMap((response) => response.json), + Effect.flatMap((json) => decodeManifest(json)), + Effect.timeout(FETCH_TIMEOUT_MS), + Effect.catchCause(() => Effect.succeed(null)), + ); + if (fetched === null) return manifest; + + manifest = fetched; + fetchedAtMs = now; + yield* encodeManifestCache({ fetchedAtMs: now, manifest: fetched }).pipe( + Effect.flatMap((serialized) => fileSystem.writeFileString(cachePath, serialized)), + Effect.catchCause(() => Effect.void), + ); + return manifest; + }); + + const guardedRefresh = refreshSemaphore.withPermits(1)(refresh()); + + return ModelManifest.of({ + current: ensureDiskCacheLoaded.pipe(Effect.map(() => manifest)), + refresh: guardedRefresh, + refreshInBackground: Effect.forkIn(guardedRefresh, serviceScope).pipe(Effect.asVoid), + }); +}); + +export const layer = Layer.effect(ModelManifest, make); diff --git a/apps/server/src/provider/OpenCodeServerOwner.test.ts b/apps/server/src/provider/OpenCodeServerOwner.test.ts new file mode 100644 index 000000000000..053c0b1bf63f --- /dev/null +++ b/apps/server/src/provider/OpenCodeServerOwner.test.ts @@ -0,0 +1,285 @@ +import { it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Ref from "effect/Ref"; +import * as TestClock from "effect/testing/TestClock"; +import { expect } from "vite-plus/test"; + +import { + OpenCodeRuntime, + OpenCodeRuntimeError, + type OpenCodeRuntimeShape, +} from "./opencodeRuntime.ts"; +import * as OpenCodeServerOwner from "./OpenCodeServerOwner.ts"; + +const unusedRuntimeMethod = () => + Effect.fail( + new OpenCodeRuntimeError({ + operation: "unused", + detail: "unused test method", + }), + ); + +const makeRuntime = Effect.gen(function* () { + const starts = yield* Ref.make(0); + const closes = yield* Ref.make(0); + const failNextStart = yield* Ref.make(false); + const started = yield* Deferred.make(); + const closed = yield* Deferred.make(); + const runtime: OpenCodeRuntimeShape = { + startOpenCodeServerProcess: () => + Effect.gen(function* () { + if (yield* Ref.getAndSet(failNextStart, false)) { + return yield* new OpenCodeRuntimeError({ + operation: "startOpenCodeServerProcess", + detail: "start failed", + }); + } + const index = yield* Ref.updateAndGet(starts, (count) => count + 1); + yield* Deferred.succeed(started, undefined).pipe(Effect.ignore); + yield* Effect.addFinalizer(() => + Ref.update(closes, (count) => count + 1).pipe( + Effect.andThen(Deferred.succeed(closed, undefined)), + Effect.ignore, + ), + ); + return { + url: `http://127.0.0.1:${index}`, + version: "1.14.19", + isRunning: Effect.succeed(true), + exitCode: Effect.never, + }; + }), + connectToOpenCodeServer: unusedRuntimeMethod, + runOpenCodeCommand: unusedRuntimeMethod, + createOpenCodeSdkClient: () => ({}) as never, + loadOpenCodeInventory: unusedRuntimeMethod, + loadInventoryFromCli: unusedRuntimeMethod, + }; + return { runtime, starts, closes, failNextStart, started, closed }; +}); + +it.effect("shares concurrent borrowers and closes after the idle TTL", () => + Effect.gen(function* () { + const testRuntime = yield* makeRuntime; + const release = yield* Deferred.make(); + yield* Effect.scoped( + Effect.gen(function* () { + const owner = yield* OpenCodeServerOwner.make({ + binaryPath: "opencode", + directory: "/project", + }); + const useServer = owner.withServer((server) => + Deferred.await(release).pipe(Effect.as(server.url)), + ); + const fibers = yield* Effect.all([useServer, useServer], { + concurrency: "unbounded", + }).pipe(Effect.forkChild); + yield* Deferred.await(testRuntime.started); + expect(yield* Ref.get(testRuntime.starts)).toBe(1); + yield* Deferred.succeed(release, undefined); + expect(yield* Fiber.join(fibers)).toEqual(["http://127.0.0.1:1", "http://127.0.0.1:1"]); + yield* TestClock.adjust(Duration.seconds(31)); + yield* Deferred.await(testRuntime.closed); + expect(yield* Ref.get(testRuntime.closes)).toBe(1); + }), + ).pipe(Effect.provideService(OpenCodeRuntime, testRuntime.runtime)); + }).pipe(Effect.provide(TestClock.layer())), +); + +it.effect("retries a failed start and closes on owner scope shutdown", () => + Effect.gen(function* () { + const testRuntime = yield* makeRuntime; + yield* Ref.set(testRuntime.failNextStart, true); + yield* Effect.scoped( + Effect.gen(function* () { + const owner = yield* OpenCodeServerOwner.make({ + binaryPath: "opencode", + directory: "/project", + }); + expect( + (yield* Effect.exit(owner.withServer((server) => Effect.succeed(server.url))))._tag, + ).toBe("Failure"); + expect(yield* owner.withServer((server) => Effect.succeed(server.url))).toBe( + "http://127.0.0.1:1", + ); + }), + ).pipe(Effect.provideService(OpenCodeRuntime, testRuntime.runtime)); + expect(yield* Ref.get(testRuntime.starts)).toBe(1); + expect(yield* Ref.get(testRuntime.closes)).toBe(1); + }), +); + +it.effect("invalidates an exited process so the next borrower starts a new one", () => + Effect.gen(function* () { + const starts = yield* Ref.make(0); + const processExits: Array> = []; + const processClosed = yield* Deferred.make(); + const runtime: OpenCodeRuntimeShape = { + startOpenCodeServerProcess: () => + Effect.gen(function* () { + const index = yield* Ref.updateAndGet(starts, (count) => count + 1); + const exitCode = yield* Deferred.make(); + processExits.push(exitCode); + yield* Effect.addFinalizer(() => + Deferred.succeed(processClosed, undefined).pipe(Effect.ignore), + ); + return { + url: `http://127.0.0.1:${index}`, + version: "1.14.19", + isRunning: Effect.succeed(true), + exitCode: Deferred.await(exitCode), + }; + }), + connectToOpenCodeServer: unusedRuntimeMethod, + runOpenCodeCommand: unusedRuntimeMethod, + createOpenCodeSdkClient: () => ({}) as never, + loadOpenCodeInventory: unusedRuntimeMethod, + loadInventoryFromCli: unusedRuntimeMethod, + }; + + yield* Effect.scoped( + Effect.gen(function* () { + const owner = yield* OpenCodeServerOwner.make({ + binaryPath: "opencode", + directory: "/project", + }); + expect(yield* owner.withServer((server) => Effect.succeed(server.url))).toBe( + "http://127.0.0.1:1", + ); + yield* Deferred.succeed(processExits[0]!, 1); + yield* Deferred.await(processClosed); + expect(yield* owner.withServer((server) => Effect.succeed(server.url))).toBe( + "http://127.0.0.1:2", + ); + }), + ).pipe(Effect.provideService(OpenCodeRuntime, runtime)); + expect(yield* Ref.get(starts)).toBe(2); + }), +); + +it.effect("replaces a dead cached process before its exit watcher runs", () => + Effect.gen(function* () { + const starts = yield* Ref.make(0); + const closes = yield* Ref.make(0); + const processRunning: Array> = []; + const runtime: OpenCodeRuntimeShape = { + startOpenCodeServerProcess: () => + Effect.gen(function* () { + const index = yield* Ref.updateAndGet(starts, (count) => count + 1); + const isRunning = yield* Ref.make(true); + processRunning.push(isRunning); + yield* Effect.addFinalizer(() => Ref.update(closes, (count) => count + 1)); + return { + url: `http://127.0.0.1:${index}`, + version: "1.14.19", + isRunning: Ref.get(isRunning), + exitCode: Effect.never, + }; + }), + connectToOpenCodeServer: unusedRuntimeMethod, + runOpenCodeCommand: unusedRuntimeMethod, + createOpenCodeSdkClient: () => ({}) as never, + loadOpenCodeInventory: unusedRuntimeMethod, + loadInventoryFromCli: unusedRuntimeMethod, + }; + + yield* Effect.scoped( + Effect.gen(function* () { + const owner = yield* OpenCodeServerOwner.make({ + binaryPath: "opencode", + directory: "/project", + }); + expect(yield* owner.withServer((server) => Effect.succeed(server.url))).toBe( + "http://127.0.0.1:1", + ); + yield* Ref.set(processRunning[0]!, false); + + expect(yield* owner.withServer((server) => Effect.succeed(server.url))).toBe( + "http://127.0.0.1:2", + ); + expect(yield* Ref.get(starts)).toBe(2); + expect(yield* Ref.get(closes)).toBe(1); + }), + ).pipe(Effect.provideService(OpenCodeRuntime, runtime)); + }), +); + +it.effect("cleans up an interrupted startup and allows a retry", () => + Effect.gen(function* () { + const starts = yield* Ref.make(0); + const firstStartEntered = yield* Deferred.make(); + const firstStartClosed = yield* Deferred.make(); + const runtime: OpenCodeRuntimeShape = { + startOpenCodeServerProcess: () => + Effect.gen(function* () { + const index = yield* Ref.updateAndGet(starts, (count) => count + 1); + yield* Effect.addFinalizer(() => + index === 1 + ? Deferred.succeed(firstStartClosed, undefined).pipe(Effect.ignore) + : Effect.void, + ); + if (index === 1) { + yield* Deferred.succeed(firstStartEntered, undefined); + return yield* Effect.never; + } + return { + url: `http://127.0.0.1:${index}`, + version: "1.14.19", + isRunning: Effect.succeed(true), + exitCode: Effect.never, + }; + }), + connectToOpenCodeServer: unusedRuntimeMethod, + runOpenCodeCommand: unusedRuntimeMethod, + createOpenCodeSdkClient: () => ({}) as never, + loadOpenCodeInventory: unusedRuntimeMethod, + loadInventoryFromCli: unusedRuntimeMethod, + }; + + yield* Effect.scoped( + Effect.gen(function* () { + const owner = yield* OpenCodeServerOwner.make({ + binaryPath: "opencode", + directory: "/project", + }); + const firstBorrower = yield* owner + .withServer((server) => Effect.succeed(server.url)) + .pipe(Effect.forkChild); + yield* Deferred.await(firstStartEntered); + yield* Fiber.interrupt(firstBorrower); + yield* Deferred.await(firstStartClosed); + expect(yield* owner.withServer((server) => Effect.succeed(server.url))).toBe( + "http://127.0.0.1:2", + ); + }), + ).pipe(Effect.provideService(OpenCodeRuntime, runtime)); + }), +); + +it.effect("releases an interrupted borrower and closes after the idle TTL", () => + Effect.gen(function* () { + const testRuntime = yield* makeRuntime; + const borrowerEntered = yield* Deferred.make(); + yield* Effect.scoped( + Effect.gen(function* () { + const owner = yield* OpenCodeServerOwner.make({ + binaryPath: "opencode", + directory: "/project", + }); + const borrower = yield* owner + .withServer(() => + Deferred.succeed(borrowerEntered, undefined).pipe(Effect.andThen(Effect.never)), + ) + .pipe(Effect.forkChild); + yield* Deferred.await(borrowerEntered); + yield* Fiber.interrupt(borrower); + yield* TestClock.adjust(Duration.seconds(31)); + yield* Deferred.await(testRuntime.closed); + expect(yield* Ref.get(testRuntime.closes)).toBe(1); + }), + ).pipe(Effect.provideService(OpenCodeRuntime, testRuntime.runtime)); + }).pipe(Effect.provide(TestClock.layer())), +); diff --git a/apps/server/src/provider/OpenCodeServerOwner.ts b/apps/server/src/provider/OpenCodeServerOwner.ts new file mode 100644 index 000000000000..cccfcaccd6ef --- /dev/null +++ b/apps/server/src/provider/OpenCodeServerOwner.ts @@ -0,0 +1,184 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; + +import * as OpenCodeRuntime from "./opencodeRuntime.ts"; + +export const OPENCODE_SERVER_IDLE_TTL = "30 seconds"; + +interface OpenCodeServerOwnerState { + server: OpenCodeRuntime.OpenCodeServerProcess | null; + serverScope: Scope.Closeable | null; + borrowers: number; + idleCloseFiber: Fiber.Fiber | null; +} + +export class OpenCodeServerOwner extends Context.Service< + OpenCodeServerOwner, + { + readonly withServer: ( + use: (server: OpenCodeRuntime.OpenCodeServerProcess) => Effect.Effect, + ) => Effect.Effect; + } +>()("t3/provider/OpenCodeServerOwner") {} + +/** Owns the lazy local OpenCode server shared by one provider instance. */ +export const make = Effect.fn("OpenCodeServerOwner.make")(function* (input: { + readonly binaryPath: string; + readonly directory: string; + readonly serverPassword?: string; + readonly environment?: NodeJS.ProcessEnv; +}) { + const runtime = yield* OpenCodeRuntime.OpenCodeRuntime; + const ownerScope = yield* Effect.acquireRelease(Scope.make(), (scope) => + Scope.close(scope, Exit.void), + ); + const mutex = yield* Semaphore.make(1); + const state: OpenCodeServerOwnerState = { + server: null, + serverScope: null, + borrowers: 0, + idleCloseFiber: null, + }; + + const cancelIdleClose = Effect.fn("OpenCodeServerOwner.cancelIdleClose")(function* () { + const fiber = state.idleCloseFiber; + state.idleCloseFiber = null; + if (fiber !== null) { + yield* Fiber.interrupt(fiber).pipe(Effect.ignore); + } + }); + + const closeServer = Effect.fn("OpenCodeServerOwner.closeServer")(function* ( + expected?: OpenCodeRuntime.OpenCodeServerProcess, + ) { + if (expected !== undefined && state.server !== expected) { + return; + } + const scope = state.serverScope; + state.server = null; + state.serverScope = null; + if (scope !== null) { + yield* Scope.close(scope, Exit.void).pipe(Effect.ignore); + } + }); + + const watchServerExit = Effect.fn("OpenCodeServerOwner.watchServerExit")(function* ( + server: OpenCodeRuntime.OpenCodeServerProcess, + ) { + yield* server.exitCode; + yield* mutex.withPermit( + Effect.gen(function* () { + if (state.server !== server) { + return; + } + yield* cancelIdleClose(); + yield* closeServer(server); + }), + ); + }); + + const acquireServer = mutex.withPermit( + Effect.gen(function* () { + yield* cancelIdleClose(); + if (state.server !== null) { + if (yield* state.server.isRunning) { + state.borrowers += 1; + return state.server; + } + yield* closeServer(state.server); + } + + return yield* Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const serverScope = yield* Scope.make(); + const started = yield* Effect.exit( + restore( + runtime + .startOpenCodeServerProcess({ + binaryPath: input.binaryPath, + directory: input.directory, + ...(input.serverPassword !== undefined + ? { serverPassword: input.serverPassword } + : {}), + ...(input.environment ? { environment: input.environment } : {}), + }) + .pipe(Effect.provideService(Scope.Scope, serverScope)), + ), + ); + if (Exit.isFailure(started)) { + yield* Scope.close(serverScope, Exit.void).pipe(Effect.ignore); + return yield* Effect.failCause(started.cause); + } + + const server = started.value; + state.server = server; + state.serverScope = serverScope; + state.borrowers = 1; + yield* watchServerExit(server).pipe(Effect.forkIn(ownerScope)); + return server; + }), + ); + }), + ); + + const releaseServer = (server: OpenCodeRuntime.OpenCodeServerProcess) => + mutex.withPermit( + Effect.gen(function* () { + if (state.server !== server) { + return; + } + state.borrowers = Math.max(0, state.borrowers - 1); + if (state.borrowers > 0) { + return; + } + yield* cancelIdleClose(); + state.idleCloseFiber = yield* Effect.sleep(OPENCODE_SERVER_IDLE_TTL).pipe( + Effect.andThen( + mutex.withPermit( + Effect.gen(function* () { + if (state.server !== server || state.borrowers > 0) { + return; + } + state.idleCloseFiber = null; + yield* closeServer(server); + }), + ), + ), + Effect.forkIn(ownerScope), + ); + }), + ); + + yield* Effect.addFinalizer(() => + mutex.withPermit( + Effect.gen(function* () { + yield* cancelIdleClose(); + state.borrowers = 0; + yield* closeServer(); + }), + ), + ); + + return OpenCodeServerOwner.of({ + withServer: (use) => + Effect.uninterruptibleMask((restore) => + restore(acquireServer).pipe( + Effect.flatMap((server) => + restore(use(server)).pipe(Effect.ensuring(releaseServer(server))), + ), + ), + ), + }); +}); + +export const layer = (input: { + readonly binaryPath: string; + readonly directory: string; + readonly serverPassword?: string; + readonly environment?: NodeJS.ProcessEnv; +}) => Layer.effect(OpenCodeServerOwner, make(input)); diff --git a/apps/server/src/provider/acp/AcpCoreRuntimeEvents.ts b/apps/server/src/provider/acp/AcpCoreRuntimeEvents.ts index bd25e9815aef..4de5247ac160 100644 --- a/apps/server/src/provider/acp/AcpCoreRuntimeEvents.ts +++ b/apps/server/src/provider/acp/AcpCoreRuntimeEvents.ts @@ -8,11 +8,15 @@ import { type ProviderRuntimeEvent, type RuntimeRequestId, type ThreadId, - type ToolLifecycleItemType, type TurnId, } from "@t3tools/contracts"; -import type { AcpPermissionRequest, AcpPlanUpdate, AcpToolCallState } from "./AcpRuntimeModel.ts"; +import { + type AcpPermissionRequest, + type AcpPlanUpdate, + type AcpToolCallState, + canonicalItemTypeFromAcpToolKind, +} from "./AcpRuntimeModel.ts"; type AcpAdapterRawSource = Extract< RuntimeEventRawSource, @@ -44,22 +48,6 @@ function canonicalRequestTypeFromAcpKind(kind: string | "unknown"): AcpCanonical } } -function canonicalItemTypeFromAcpToolKind(kind: string | undefined): ToolLifecycleItemType { - switch (kind) { - case "execute": - return "command_execution"; - case "edit": - case "delete": - case "move": - return "file_change"; - case "search": - case "fetch": - return "web_search"; - default: - return "dynamic_tool_call"; - } -} - function runtimeItemStatusFromAcpToolStatus( status: AcpToolCallState["status"], ): "inProgress" | "completed" | "failed" | undefined { diff --git a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts index b1ef0d3e5953..93ffc63806f6 100644 --- a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts +++ b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts @@ -330,7 +330,7 @@ describe("AcpSessionRuntime", () => { ), ); - it.effect("suppresses generic placeholder tool updates until completion", () => + it.effect("emits status-only tool updates through completion", () => Effect.gen(function* () { const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; yield* runtime.start(); @@ -340,13 +340,22 @@ describe("AcpSessionRuntime", () => { }); expect(promptResult).toMatchObject({ stopReason: "end_turn" }); - const notes = Array.from(yield* Stream.runCollect(Stream.take(runtime.getEvents(), 1))); - expect(notes.map((note) => note._tag)).toEqual(["ToolCallUpdated"]); - const toolCall = notes[0]; - expect(toolCall?._tag).toBe("ToolCallUpdated"); - if (toolCall?._tag === "ToolCallUpdated") { - expect(toolCall.toolCall.status).toBe("completed"); - expect(toolCall.toolCall.title).toBe("Read file"); + const notes = Array.from(yield* Stream.runCollect(Stream.take(runtime.getEvents(), 3))); + expect(notes.map((note) => note._tag)).toEqual([ + "ToolCallUpdated", + "ToolCallUpdated", + "ToolCallUpdated", + ]); + const toolCalls = notes.flatMap((note) => + note._tag === "ToolCallUpdated" ? [note.toolCall] : [], + ); + expect(toolCalls.map((toolCall) => toolCall.status)).toEqual([ + "pending", + "inProgress", + "completed", + ]); + for (const toolCall of toolCalls) { + expect(toolCall.title).toBe("Read file"); } }).pipe( Effect.provide( diff --git a/apps/server/src/provider/acp/AcpNativeLogging.test.ts b/apps/server/src/provider/acp/AcpNativeLogging.test.ts index 7c949e040599..84926fbe1d61 100644 --- a/apps/server/src/provider/acp/AcpNativeLogging.test.ts +++ b/apps/server/src/provider/acp/AcpNativeLogging.test.ts @@ -28,6 +28,7 @@ nodeServicesIt("ACP native logging", (it) => { nativeEventLogger, provider: ProviderDriverKind.make("cursor"), threadId: ThreadId.make("thread-1"), + verboseProtocolLogging: true, }); const secret = "secret-token-value"; const requestLogger = logger.requestLogger; @@ -67,6 +68,174 @@ nodeServicesIt("ACP native logging", (it) => { }), ); + it.effect("keeps request diagnostics without enabling full protocol logging", () => + Effect.gen(function* () { + const records: Array = []; + const makeLogger = yield* makeAcpNativeLoggerFactory(); + const logger = makeLogger({ + nativeEventLogger: { + filePath: "/tmp/provider-native.ndjson", + write: (event) => Effect.sync(() => void records.push(event)), + close: () => Effect.void, + }, + provider: ProviderDriverKind.make("grok"), + threadId: ThreadId.make("thread-1"), + }); + + assert.isUndefined(logger.protocolLogging); + const requestLogger = logger.requestLogger; + assert.exists(requestLogger); + if (!requestLogger) return; + yield* requestLogger({ + method: "session/prompt", + payload: {}, + status: "started", + }); + assert.lengthOf(records, 1); + }), + ); + + it.effect("drops transient ACP chunks before formatting verbose protocol logs", () => + Effect.gen(function* () { + const records: Array = []; + const makeLogger = yield* makeAcpNativeLoggerFactory(); + const logger = makeLogger({ + nativeEventLogger: { + filePath: "/tmp/provider-native.ndjson", + write: (event) => Effect.sync(() => void records.push(event)), + close: () => Effect.void, + }, + provider: ProviderDriverKind.make("cursor"), + threadId: ThreadId.make("thread-1"), + verboseProtocolLogging: true, + }); + const protocolLogger = logger.protocolLogging?.logger; + assert.exists(protocolLogger); + if (!protocolLogger) return; + + for (const updateType of ["agent_message_chunk", "agent_thought_chunk"] as const) { + yield* protocolLogger({ + direction: "incoming", + stage: "raw", + payload: `${encodeUnknownJson({ + method: "session/update", + params: { update: { sessionUpdate: updateType } }, + })}\n`, + }); + yield* protocolLogger({ + direction: "incoming", + stage: "decoded", + payload: [ + { + _tag: "Request", + tag: "session/update", + payload: { update: { sessionUpdate: updateType } }, + }, + ], + }); + } + + assert.lengthOf(records, 0); + + yield* protocolLogger({ + direction: "incoming", + stage: "decoded", + payload: [ + { + _tag: "Request", + tag: "session/update", + payload: { update: { sessionUpdate: "tool_call" } }, + }, + ], + }); + assert.lengthOf(records, 1); + }), + ); + + it.effect("keeps mixed and incomplete raw diagnostics", () => + Effect.gen(function* () { + const records: Array = []; + const makeLogger = yield* makeAcpNativeLoggerFactory(); + const logger = makeLogger({ + nativeEventLogger: { + filePath: "/tmp/provider-native.ndjson", + write: (event) => Effect.sync(() => void records.push(event)), + close: () => Effect.void, + }, + provider: ProviderDriverKind.make("cursor"), + threadId: ThreadId.make("thread-1"), + verboseProtocolLogging: true, + }); + const protocolLogger = logger.protocolLogging?.logger; + assert.exists(protocolLogger); + if (!protocolLogger) return; + + const transient = encodeUnknownJson({ + method: "session/update", + params: { update: { sessionUpdate: "agent_message_chunk" } }, + }); + const lifecycle = encodeUnknownJson({ method: "session/new", params: {} }); + + yield* protocolLogger({ + direction: "incoming", + stage: "raw", + payload: `${transient}\n${lifecycle}\n`, + }); + yield* protocolLogger({ + direction: "incoming", + stage: "raw", + payload: transient, + }); + yield* protocolLogger({ + direction: "incoming", + stage: "raw", + payload: `${transient}\n{malformed}\n`, + }); + + assert.lengthOf(records, 3); + }), + ); + + it.effect("filters transient entries from mixed decoded batches", () => + Effect.gen(function* () { + const records: Array = []; + const makeLogger = yield* makeAcpNativeLoggerFactory(); + const logger = makeLogger({ + nativeEventLogger: { + filePath: "/tmp/provider-native.ndjson", + write: (event) => Effect.sync(() => void records.push(event)), + close: () => Effect.void, + }, + provider: ProviderDriverKind.make("grok"), + threadId: ThreadId.make("thread-1"), + verboseProtocolLogging: true, + }); + const protocolLogger = logger.protocolLogging?.logger; + assert.exists(protocolLogger); + if (!protocolLogger) return; + + yield* protocolLogger({ + direction: "incoming", + stage: "decoded", + payload: [ + { + _tag: "Request", + tag: "session/update", + payload: { update: { sessionUpdate: "agent_thought_chunk" } }, + }, + { + _tag: "Request", + tag: "session/new", + payload: {}, + }, + ], + }); + + assert.lengthOf(records, 1); + assert.include(encodeUnknownJson(records), '"itemCount":1'); + }), + ); + it.effect("logs a structural tag when the native writer defects", () => { const messages: Array = []; const logCapture = Logger.make(({ message }) => { diff --git a/apps/server/src/provider/acp/AcpNativeLogging.ts b/apps/server/src/provider/acp/AcpNativeLogging.ts index 06bff3aa6113..6d1bf6209d5d 100644 --- a/apps/server/src/provider/acp/AcpNativeLogging.ts +++ b/apps/server/src/provider/acp/AcpNativeLogging.ts @@ -9,6 +9,8 @@ import type * as EffectAcpProtocol from "effect-acp/protocol"; import type { EventNdjsonLogger } from "../Layers/EventNdjsonLogger.ts"; import type * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; +const transientProtocolUpdates = new Set(["agent_message_chunk", "agent_thought_chunk"]); + function structuralMethod(value: string): string { return value.length <= 128 && /^[A-Za-z][A-Za-z0-9._:/-]*$/.test(value) ? value : "unknown"; } @@ -64,12 +66,61 @@ function formatProtocolLogPayload(event: EffectAcpProtocol.AcpProtocolLogEvent) }; } +function isTransientProtocolMessage(message: unknown): boolean { + if (typeof message !== "object" || message === null) return false; + const method = Reflect.get(message, "tag") ?? Reflect.get(message, "method"); + if (method !== "session/update") return false; + + const payload = Reflect.get(message, "payload") ?? Reflect.get(message, "params"); + if (typeof payload !== "object" || payload === null) return false; + const update = Reflect.get(payload, "update"); + if (typeof update !== "object" || update === null) return false; + const updateType = Reflect.get(update, "sessionUpdate"); + return typeof updateType === "string" && transientProtocolUpdates.has(updateType); +} + +function rawChunkContainsOnlyTransientMessages(payload: string): boolean { + const lines = payload.split("\n"); + const remainder = lines.pop() ?? ""; + if (remainder.trim().length > 0) return false; + + const messages: Array = []; + for (const line of lines) { + if (line.trim().length === 0) continue; + try { + messages.push(JSON.parse(line)); + } catch { + return false; + } + } + return messages.length > 0 && messages.every(isTransientProtocolMessage); +} + +function filterTransientProtocolLog( + event: EffectAcpProtocol.AcpProtocolLogEvent, +): EffectAcpProtocol.AcpProtocolLogEvent | undefined { + if (event.direction !== "incoming") return event; + + if (event.stage === "raw" && typeof event.payload === "string") { + return rawChunkContainsOnlyTransientMessages(event.payload) ? undefined : event; + } + + if (event.stage !== "decoded") return event; + if (!Array.isArray(event.payload)) { + return isTransientProtocolMessage(event.payload) ? undefined : event; + } + + const payload = event.payload.filter((message) => !isTransientProtocolMessage(message)); + return payload.length === 0 ? undefined : { ...event, payload }; +} + export const makeAcpNativeLoggerFactory = Effect.fn("makeAcpNativeLoggerFactory")(function* () { const crypto = yield* Crypto.Crypto; return (input: { readonly nativeEventLogger: EventNdjsonLogger | undefined; readonly provider: ProviderDriverKind; readonly threadId: ThreadId; + readonly verboseProtocolLogging?: boolean; }): Pick => { const writeNativeAcpLog = (logInput: { readonly kind: "request" | "protocol"; @@ -111,16 +162,20 @@ export const makeAcpNativeLoggerFactory = Effect.fn("makeAcpNativeLoggerFactory" kind: "request", payload: formatRequestLogPayload(event), }), - ...(input.nativeEventLogger + ...(input.nativeEventLogger && input.verboseProtocolLogging ? { protocolLogging: { logIncoming: true, logOutgoing: true, - logger: (event: EffectAcpProtocol.AcpProtocolLogEvent) => - writeNativeAcpLog({ - kind: "protocol", - payload: formatProtocolLogPayload(event), - }), + logger: (event: EffectAcpProtocol.AcpProtocolLogEvent) => { + const filtered = filterTransientProtocolLog(event); + return filtered + ? writeNativeAcpLog({ + kind: "protocol", + payload: formatProtocolLogPayload(filtered), + }) + : Effect.void; + }, } satisfies NonNullable, } : {}), diff --git a/apps/server/src/provider/acp/AcpRuntimeModel.test.ts b/apps/server/src/provider/acp/AcpRuntimeModel.test.ts index 7682c5f5f9cb..9e5075a5f70a 100644 --- a/apps/server/src/provider/acp/AcpRuntimeModel.test.ts +++ b/apps/server/src/provider/acp/AcpRuntimeModel.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vite-plus/test"; import type * as EffectAcpSchema from "effect-acp/schema"; import { + decideToolCallUpdateEmission, extractModelConfigId, mergeToolCallState, parsePermissionRequest, @@ -10,6 +11,8 @@ import { parseSessionUpdateEvent, sessionUpdateIsReplay, syntheticLoadSessionResponseFromInitialize, + toolCallProgressLength, + type AcpToolCallState, } from "./AcpRuntimeModel.ts"; describe("AcpRuntimeModel", () => { @@ -374,4 +377,466 @@ describe("AcpRuntimeModel", () => { }, }); }); + + it("bounds an oversized cumulative tool_call_update content buffer to a tail window", () => { + // Mirrors Grok's ACP CLI resending the ENTIRE accumulated terminal output on every + // tool_call_update notification instead of a delta (see upstream #6556). + const hugeText = Array.from({ length: 2_000 }, (_, i) => `line ${i}: ${"x".repeat(50)}`).join( + "\n", + ); + expect(hugeText.length).toBeGreaterThan(60_000); + + const result = parseSessionUpdateEvent({ + sessionId: "session-1", + update: { + // Real ACP `tool_call_update` deltas typically omit `title` (already established by + // the initial `tool_call`); that is also the shape that surfaces raw content as detail. + sessionUpdate: "tool_call_update", + toolCallId: "tool-1", + kind: "other", + status: "in_progress", + content: [{ type: "content", content: { type: "text", text: hugeText } }], + }, + } satisfies EffectAcpSchema.SessionNotification); + + expect(result.events).toHaveLength(1); + const event = result.events[0]; + if (event?._tag !== "ToolCallUpdated") { + throw new Error("expected a ToolCallUpdated event"); + } + + expect(event.toolCall.detail).toBeDefined(); + const detail = event.toolCall.detail!; + // 8000 chars of tail plus the truncation marker, regardless of input size. + expect(detail.length).toBe(8_028); + expect(detail.startsWith("[Earlier output truncated]")).toBe(true); + expect(detail.endsWith(hugeText.slice(-100))).toBe(true); + + // The raw payload threaded through for logging/persistence must not smuggle the full + // cumulative buffer back in either. + const rawUpdate = ( + event.rawPayload as { + readonly update: { + readonly content: ReadonlyArray<{ readonly content: { text: string } }>; + }; + } + ).update; + expect(rawUpdate.content[0]?.content.text.length).toBeLessThan(8_100); + expect(JSON.stringify(event).length).toBeLessThan(hugeText.length); + }); + + it("coalesces 1000 rapid cumulative tool_call_update notifications for a redrawing progress bar", () => { + let previous: AcpToolCallState | undefined; + let lastEmittedDetailLength: number | undefined; + let skippedSinceEmit = 0; + let emittedCount = 0; + let emittedBytes = 0; + let notificationBytes = 0; + let largestEmittedEventBytes = 0; + let finalDetail: string | undefined; + let cumulativeBuffer = ""; + + for (let i = 0; i < 1_000; i += 1) { + // Grok resends the FULL accumulated buffer, not a delta, on every redraw. + cumulativeBuffer += `frame ${i}: ${"#".repeat(50)}\n`; + const isLast = i === 999; + + const notification = { + sessionId: "session-1", + update: { + sessionUpdate: "tool_call_update", + toolCallId: "tool-1", + kind: "other", + status: isLast ? "completed" : "in_progress", + content: [{ type: "content", content: { type: "text", text: cumulativeBuffer } }], + }, + } satisfies EffectAcpSchema.SessionNotification; + notificationBytes += JSON.stringify(notification).length; + + const { events } = parseSessionUpdateEvent(notification); + + const event = events[0]; + if (event?._tag !== "ToolCallUpdated") { + continue; + } + + const merged = mergeToolCallState(previous, event.toolCall); + const decision = decideToolCallUpdateEmission({ + previous, + next: merged, + lastEmittedDetailLength, + skippedSinceEmit, + }); + previous = merged; + skippedSinceEmit = decision.skippedSinceEmit; + if (decision.emit) { + emittedCount += 1; + const eventBytes = JSON.stringify({ + toolCall: merged, + rawPayload: event.rawPayload, + }).length; + emittedBytes += eventBytes; + largestEmittedEventBytes = Math.max(largestEmittedEventBytes, eventBytes); + lastEmittedDetailLength = merged.detail?.length; + finalDetail = merged.detail; + } + } + + // The flood as the CLI sends it: 1000 cumulative redraws, ~31.6 MB of JSON. + expect(notificationBytes).toBeGreaterThan(31_000_000); + + // 1000 cumulative redraws collapse into a fixed, small number of runtime events... + expect(emittedCount).toBe(114); + // ...each individually bounded, no matter how long the tool call runs... + expect(largestEmittedEventBytes).toBeLessThan(25_000); + // ...so the whole flooding tool call costs ~2.5 MB of runtime events instead of ~31.6 MB. + expect(emittedBytes).toBeLessThan(2_600_000); + // ...while the FINAL state (forced by the completed status) still reflects the real, + // latest output rather than a stale coalesced value. + expect(finalDetail).toBeDefined(); + expect(finalDetail?.endsWith(`frame 999: ${"#".repeat(50)}`)).toBe(true); + }); + + it("keeps non-text tool call content entries in order when bounding oversized text", () => { + const hugePrefix = "x".repeat(25_000); + const hugeTail = "y".repeat(25_000); + const { events } = parseSessionUpdateEvent({ + sessionId: "session-1", + update: { + sessionUpdate: "tool_call_update", + toolCallId: "tool-1", + kind: "edit", + status: "in_progress", + content: [ + { type: "content", content: { type: "text", text: hugePrefix } }, + { type: "diff", path: "/repo/file.ts", oldText: "before", newText: "after" }, + { type: "content", content: { type: "text", text: hugeTail } }, + { type: "diff", path: "/repo/other.ts", oldText: "old", newText: "new" }, + { type: "content", content: { type: "text", text: " " } }, + ], + }, + } satisfies EffectAcpSchema.SessionNotification); + + const event = events[0]; + if (event?._tag !== "ToolCallUpdated") { + throw new Error("expected a ToolCallUpdated event"); + } + const content = event.toolCall.data.content as ReadonlyArray; + expect(content).toHaveLength(3); + expect(content[0]).toEqual({ + type: "diff", + path: "/repo/file.ts", + oldText: "before", + newText: "after", + }); + const lastEntry = content[1]; + if (lastEntry?.type !== "content" || lastEntry.content.type !== "text") { + throw new Error("expected a bounded text entry"); + } + expect(lastEntry.content.text.length).toBeLessThan(8_100); + expect(lastEntry.content.text.endsWith(hugeTail.slice(-100))).toBe(true); + expect(content[2]).toEqual({ + type: "diff", + path: "/repo/other.ts", + oldText: "old", + newText: "new", + }); + }); + + it("keeps a retained tail on the original text entries around non-text content", () => { + const prefix = "a".repeat(4_000); + const suffix = "b".repeat(5_000); + const { events } = parseSessionUpdateEvent({ + sessionId: "session-1", + update: { + sessionUpdate: "tool_call_update", + toolCallId: "tool-1", + kind: "edit", + status: "in_progress", + content: [ + { type: "content", content: { type: "text", text: prefix } }, + { type: "diff", path: "/repo/file.ts", oldText: "before", newText: "after" }, + { type: "content", content: { type: "text", text: suffix } }, + ], + }, + } satisfies EffectAcpSchema.SessionNotification); + + const event = events[0]; + if (event?._tag !== "ToolCallUpdated") { + throw new Error("expected a ToolCallUpdated event"); + } + const content = event.toolCall.data.content as ReadonlyArray; + expect(content).toHaveLength(3); + const firstText = content[0]; + if (firstText?.type !== "content" || firstText.content.type !== "text") { + throw new Error("expected a bounded prefix text entry"); + } + expect(firstText.content.text.startsWith("[Earlier output truncated]")).toBe(true); + expect(firstText.content.text.endsWith("a".repeat(100))).toBe(true); + expect(content[1]).toEqual({ + type: "diff", + path: "/repo/file.ts", + oldText: "before", + newText: "after", + }); + expect(content[2]).toEqual({ + type: "content", + content: { type: "text", text: suffix }, + }); + }); + + it("bounds oversized whitespace-only tool call content that has no trimmed text", () => { + // Whitespace-only entries are skipped when extracting display text (`chunks.length === 0`) + // and used to be returned unchanged, which let a redrawing terminal persist unbounded + // buffers on `toolCall.data.content` and `rawPayload`. + const hugeWhitespace = " \n\t".repeat(30_000); + expect(hugeWhitespace.length).toBeGreaterThan(60_000); + + const { events } = parseSessionUpdateEvent({ + sessionId: "session-1", + update: { + sessionUpdate: "tool_call_update", + toolCallId: "tool-1", + kind: "other", + status: "in_progress", + content: [{ type: "content", content: { type: "text", text: hugeWhitespace } }], + }, + } satisfies EffectAcpSchema.SessionNotification); + + const event = events[0]; + if (event?._tag !== "ToolCallUpdated") { + throw new Error("expected a ToolCallUpdated event"); + } + + expect(event.toolCall.detail).toBeUndefined(); + const content = event.toolCall.data.content as ReadonlyArray; + const textEntry = content[0]; + if (textEntry?.type !== "content" || textEntry.content.type !== "text") { + throw new Error("expected a bounded text entry"); + } + expect(textEntry.content.text.length).toBeLessThan(8_100); + expect(textEntry.content.text.startsWith("[Earlier output truncated]")).toBe(true); + + const rawUpdate = ( + event.rawPayload as { + readonly update: { + readonly content: ReadonlyArray<{ readonly content: { text: string } }>; + }; + } + ).update; + expect(rawUpdate.content[0]?.content.text.length).toBeLessThan(8_100); + expect(JSON.stringify(event).length).toBeLessThan(hugeWhitespace.length); + }); + + it("bounds oversized whitespace-padded text entries even when trimmed content fits", () => { + const padded = `${" ".repeat(40_000)}ok${" ".repeat(40_000)}`; + expect(padded.length).toBeGreaterThan(60_000); + + const { events } = parseSessionUpdateEvent({ + sessionId: "session-1", + update: { + sessionUpdate: "tool_call_update", + toolCallId: "tool-1", + kind: "other", + status: "in_progress", + content: [{ type: "content", content: { type: "text", text: padded } }], + }, + } satisfies EffectAcpSchema.SessionNotification); + + const event = events[0]; + if (event?._tag !== "ToolCallUpdated") { + throw new Error("expected a ToolCallUpdated event"); + } + + expect(event.toolCall.detail).toBe("ok"); + const content = event.toolCall.data.content as ReadonlyArray; + const textEntry = content[0]; + if (textEntry?.type !== "content" || textEntry.content.type !== "text") { + throw new Error("expected a bounded text entry"); + } + expect(textEntry.content.text).toBe("ok"); + + const rawUpdate = ( + event.rawPayload as { + readonly update: { + readonly content: ReadonlyArray<{ readonly content: { text: string } }>; + }; + } + ).update; + expect(rawUpdate.content[0]?.content.text).toBe("ok"); + expect(JSON.stringify(event).length).toBeLessThan(padded.length); + }); + + describe("decideToolCallUpdateEmission", () => { + const toolCall = (detail: string | undefined, status?: AcpToolCallState["status"]) => + ({ + toolCallId: "tool-1", + title: "Grok Tool", + ...(status ? { status } : {}), + ...(detail ? { detail } : {}), + data: {}, + }) satisfies AcpToolCallState; + + it("emits the first in-progress tool_call even when it has no detail", () => { + expect( + decideToolCallUpdateEmission({ + previous: undefined, + next: { toolCallId: "tool-1", title: "Grok Tool", status: "pending", data: {} }, + lastEmittedDetailLength: undefined, + skippedSinceEmit: 0, + }), + ).toEqual({ emit: true, skippedSinceEmit: 0 }); + }); + + it("always emits terminal (completed/failed) status updates regardless of growth", () => { + expect( + decideToolCallUpdateEmission({ + previous: toolCall("same", "inProgress"), + next: toolCall("same", "completed"), + lastEmittedDetailLength: 4, + skippedSinceEmit: 0, + }), + ).toEqual({ emit: true, skippedSinceEmit: 0 }); + + expect( + decideToolCallUpdateEmission({ + previous: toolCall("same", "inProgress"), + next: toolCall("same", "failed"), + lastEmittedDetailLength: 4, + skippedSinceEmit: 3, + }), + ).toEqual({ emit: true, skippedSinceEmit: 0 }); + }); + + it("skips updates whose bounded detail did not change", () => { + const previous = toolCall("frame 1", "inProgress"); + expect( + decideToolCallUpdateEmission({ + previous, + next: previous, + lastEmittedDetailLength: 7, + skippedSinceEmit: 0, + }), + ).toEqual({ emit: false, skippedSinceEmit: 0 }); + }); + + it("coalesces command-tool updates whose content grew while detail stayed the command", () => { + const commandCall = (stdout: string): AcpToolCallState => ({ + toolCallId: "tool-1", + title: "Ran command", + status: "inProgress", + command: "ls", + detail: "ls", + data: { + command: "ls", + content: [{ type: "content", content: { type: "text", text: stdout } }], + }, + }); + + let previous: AcpToolCallState | undefined; + let lastEmittedDetailLength: number | undefined; + let skippedSinceEmit = 0; + const emissions: Array = []; + + for (let i = 1; i <= 12; i += 1) { + const next = commandCall("x".repeat(i)); + const decision = decideToolCallUpdateEmission({ + previous, + next, + lastEmittedDetailLength, + skippedSinceEmit, + }); + emissions.push(decision.emit); + skippedSinceEmit = decision.skippedSinceEmit; + if (decision.emit) { + lastEmittedDetailLength = toolCallProgressLength(next); + } + previous = next; + } + + const emittedIndices = emissions.flatMap((emitted, index) => (emitted ? [index + 1] : [])); + expect(emittedIndices).toEqual([1, 11]); + }); + + it("emits pending to inProgress status changes even when detail and output are unchanged", () => { + expect( + decideToolCallUpdateEmission({ + previous: toolCall("same", "pending"), + next: toolCall("same", "inProgress"), + lastEmittedDetailLength: 4, + skippedSinceEmit: 0, + }), + ).toEqual({ emit: true, skippedSinceEmit: 0 }); + }); + + it("emits immediately when the title changes, even with no growth", () => { + const decision = decideToolCallUpdateEmission({ + previous: { toolCallId: "tool-1", title: "Reading file", detail: "x", data: {} }, + next: { toolCallId: "tool-1", title: "Ran command", detail: "x", data: {} }, + lastEmittedDetailLength: 1, + skippedSinceEmit: 0, + }); + expect(decision).toEqual({ emit: true, skippedSinceEmit: 0 }); + }); + + it("coalesces small deltas but forces an emission after the coalesce limit", () => { + let lastEmittedDetailLength: number | undefined = 0; + let skippedSinceEmit = 0; + const emissions: Array = []; + let previous: AcpToolCallState | undefined; + + for (let i = 1; i <= 12; i += 1) { + // Grows by 1 char per update — well under the 256-char growth threshold, so this + // exercises the coalesce-count fallback rather than the growth-based trigger. + const next = toolCall("x".repeat(i), "inProgress"); + const decision = decideToolCallUpdateEmission({ + previous, + next, + lastEmittedDetailLength, + skippedSinceEmit, + }); + emissions.push(decision.emit); + skippedSinceEmit = decision.skippedSinceEmit; + if (decision.emit) { + lastEmittedDetailLength = next.detail?.length; + } + previous = next; + } + + // First update always emits (no previous state yet); after that, small per-update + // growth should be coalesced until the coalesce limit forces a periodic emission. + const emittedIndices = emissions.flatMap((emitted, index) => (emitted ? [index + 1] : [])); + expect(emittedIndices).toEqual([1, 11]); + }); + + it("retains the latest replacement snapshot when equal-length updates are coalesced", () => { + let previous: AcpToolCallState = toolCall("frame-a", "inProgress"); + const lastEmittedDetailLength = previous.detail?.length; + let skippedSinceEmit = 0; + + for (const detail of ["frame-b", "frame-c"]) { + const next = mergeToolCallState(previous, toolCall(detail, "inProgress")); + const decision = decideToolCallUpdateEmission({ + previous, + next, + lastEmittedDetailLength, + skippedSinceEmit, + }); + expect(decision.emit).toBe(false); + skippedSinceEmit = decision.skippedSinceEmit; + previous = next; + } + + const completed = mergeToolCallState(previous, toolCall(undefined, "completed")); + expect(completed.detail).toBe("frame-c"); + expect( + decideToolCallUpdateEmission({ + previous, + next: completed, + lastEmittedDetailLength, + skippedSinceEmit, + }), + ).toEqual({ emit: true, skippedSinceEmit: 0 }); + }); + }); }); diff --git a/apps/server/src/provider/acp/AcpRuntimeModel.ts b/apps/server/src/provider/acp/AcpRuntimeModel.ts index e6bfc127e6e9..a81a2faf5e04 100644 --- a/apps/server/src/provider/acp/AcpRuntimeModel.ts +++ b/apps/server/src/provider/acp/AcpRuntimeModel.ts @@ -264,32 +264,177 @@ function extractToolCallCommand(rawInput: unknown, title: string | undefined): s return extractCommandFromTitle(title); } +// Some ACP agents (observed with Grok's CLI) resend the ENTIRE accumulated tool-call +// output on every `tool_call_update` notification instead of a delta, so a redrawing +// terminal progress bar can balloon a single tool call to hundreds of KB per update at +// several updates per second. Cap what we retain/emit to a bounded tail so one busy tool +// call cannot flood runtime event ingestion. We always keep the tail: `tool_call_update` +// deltas routinely omit `kind`, so there is no reliable way to tell a redrawing terminal +// from another tool here, and the end is the useful part of any live-growing output. +const TOOL_CALL_CONTENT_MAX_CHARS = 8_000; +const TOOL_CALL_CONTENT_TRUNCATION_MARKER = "[Earlier output truncated]\n\n"; + +function boundToolCallOutputText(text: string): string { + if (text.length <= TOOL_CALL_CONTENT_MAX_CHARS) { + return text; + } + const tail = text.slice(text.length - TOOL_CALL_CONTENT_MAX_CHARS); + return `${TOOL_CALL_CONTENT_TRUNCATION_MARKER}${tail}`; +} + +const RAW_OUTPUT_TEXT_FIELDS = ["content", "stdout", "stderr", "output"] as const; + +// `rawOutput` is provider-defined and, for terminal-shaped tools, mirrors the same +// cumulative text-growth problem as `content` (see the comment above). Bound its known +// text-bearing fields the same way so a chatty provider cannot smuggle unbounded output +// through this field instead. +function boundToolCallRawOutput(rawOutput: unknown): unknown { + if (!isRecord(rawOutput)) { + return rawOutput; + } + let changed = false; + const bounded: Record = { ...rawOutput }; + for (const field of RAW_OUTPUT_TEXT_FIELDS) { + const value = rawOutput[field]; + if (typeof value === "string" && value.length > TOOL_CALL_CONTENT_MAX_CHARS) { + bounded[field] = boundToolCallOutputText(value); + changed = true; + } + } + return changed ? bounded : rawOutput; +} + +interface ExtractedToolCallContent { + readonly text: string | undefined; + readonly content: ReadonlyArray | undefined; +} + +function toolCallContentText(entry: EffectAcpSchema.ToolCallContent): string | undefined { + if (entry.type !== "content" || entry.content.type !== "text") { + return undefined; + } + return entry.content.text; +} + +// Trim is used for display `text`, so whitespace-only (or whitespace-padded) entries never +// contribute to `chunks` and used to take the early returns with the original array. Bound +// each text entry independently so those paths cannot persist an unbounded terminal buffer +// on `toolCall.data.content` / `rawPayload`. +function boundToolCallContentEntries( + content: ReadonlyArray, +): ReadonlyArray { + let changed = false; + const bounded = content.map((entry) => { + const text = toolCallContentText(entry); + if (text === undefined || text.length <= TOOL_CALL_CONTENT_MAX_CHARS) { + return entry; + } + changed = true; + const trimmed = text.trim(); + return { + type: "content", + content: { + type: "text", + text: boundToolCallOutputText(trimmed.length > 0 ? trimmed : text), + }, + } as const; + }); + return changed ? bounded : content; +} + function extractTextContentFromToolCallContent( content: ReadonlyArray | null | undefined, -): string | undefined { - if (!content) return undefined; +): ExtractedToolCallContent { + if (!content) { + return { text: undefined, content: undefined }; + } const chunks: Array = []; for (const entry of content) { - if (entry.type !== "content") { - continue; + const text = toolCallContentText(entry)?.trim(); + if (text) { + chunks.push(text); } - const nestedContent = entry.content; - if (nestedContent.type !== "text") { + } + if (chunks.length === 0) { + return { text: undefined, content: boundToolCallContentEntries(content) }; + } + const joined = chunks.join("\n"); + if (joined.length <= TOOL_CALL_CONTENT_MAX_CHARS) { + return { text: joined, content: boundToolCallContentEntries(content) }; + } + const bounded = boundToolCallOutputText(joined); + const tail = joined.slice(joined.length - TOOL_CALL_CONTENT_MAX_CHARS); + return { + text: bounded, + content: distributeRetainedTailAcrossContent(content, tail), + }; +} + +// Walk the original text entries from the joined tail window so a retained slice that +// spans entries around an image/diff stays on those entries. Non-text kinds keep their +// relative order; blank text entries are dropped; the truncation marker is prepended to +// the first remaining text entry. +function distributeRetainedTailAcrossContent( + content: ReadonlyArray, + tail: string, +): ReadonlyArray { + const textRanges: Array< + | { + readonly start: number; + readonly end: number; + readonly text: string; + } + | undefined + > = Array.from({ length: content.length }); + let offset = 0; + let seenText = false; + for (const [index, entry] of content.entries()) { + const text = toolCallContentText(entry)?.trim(); + if (!text) { continue; } - const text = nestedContent.text.trim(); - if (text.length > 0) { - chunks.push(text); + if (seenText) { + offset += 1; } + seenText = true; + const start = offset; + const end = offset + text.length; + textRanges[index] = { start, end, text }; + offset = end; } - return chunks.length > 0 ? chunks.join("\n") : undefined; + const tailStart = Math.max(0, offset - tail.length); + let markerPending = true; + return content.flatMap((entry, index) => { + if (toolCallContentText(entry) === undefined) { + return [entry]; + } + const range = textRanges[index]; + if (range === undefined) { + return []; + } + const overlapStart = Math.max(range.start, tailStart); + const overlapEnd = Math.min(range.end, offset); + if (overlapEnd <= overlapStart) { + return []; + } + let piece = range.text.slice(overlapStart - range.start, overlapEnd - range.start); + if (markerPending) { + piece = `${TOOL_CALL_CONTENT_TRUNCATION_MARKER}${piece}`; + markerPending = false; + } + return [{ type: "content", content: { type: "text", text: piece } } as const]; + }); } function normalizeToolKind(kind: unknown): string | undefined { return typeof kind === "string" && kind.trim().length > 0 ? kind.trim() : undefined; } -function canonicalItemTypeFromAcpToolKind(kind: string | undefined): ToolLifecycleItemType { +/** + * Map an ACP tool kind onto the canonical runtime item type used by the + * thread activity model. Unknown kinds fall back to a generic tool call. + */ +export function canonicalItemTypeFromAcpToolKind(kind: string | undefined): ToolLifecycleItemType { switch (kind) { case "execute": return "command_execution"; @@ -326,7 +471,8 @@ function makeToolCallState( } const title = input.title?.trim() || undefined; const command = extractToolCallCommand(input.rawInput, title); - const textContent = extractTextContentFromToolCallContent(input.content); + const extractedContent = extractTextContentFromToolCallContent(input.content); + const textContent = extractedContent.text; const normalizedTitle = title && title.toLowerCase() !== "terminal" && title.toLowerCase() !== "tool call" ? title @@ -343,10 +489,10 @@ function makeToolCallState( data.rawInput = input.rawInput; } if (input.rawOutput !== undefined) { - data.rawOutput = input.rawOutput; + data.rawOutput = boundToolCallRawOutput(input.rawOutput); } if (input.content !== undefined) { - data.content = input.content; + data.content = extractedContent.content ?? input.content; } if (input.locations !== undefined) { data.locations = input.locations; @@ -424,6 +570,86 @@ export function mergeToolCallState( }; } +// Even with bounded content (see TOOL_CALL_CONTENT_MAX_CHARS above), a redrawing terminal +// can still shift its bounded tail window on nearly every notification, which would emit +// a runtime event per redraw. Coalesce those: only emit early when the tool call's detail +// has grown meaningfully since the last emission, otherwise batch up to a small number of +// skipped updates before emitting anyway, so the UI still gets periodic progress and the +// final (completed/failed) state is always emitted immediately. +const TOOL_CALL_UPDATE_MIN_DETAIL_GROWTH_CHARS = 256; +const TOOL_CALL_UPDATE_COALESCE_LIMIT = 10; + +export interface AcpToolCallEmitDecisionInput { + readonly previous: AcpToolCallState | undefined; + readonly next: AcpToolCallState; + readonly lastEmittedDetailLength: number | undefined; + readonly skippedSinceEmit: number; +} + +export interface AcpToolCallEmitDecision { + readonly emit: boolean; + readonly skippedSinceEmit: number; +} + +function toolCallOutputUnchanged(previous: AcpToolCallState, next: AcpToolCallState): boolean { + return ( + previous.data.content === next.data.content && previous.data.rawOutput === next.data.rawOutput + ); +} + +// Command tools keep `detail` equal to the command, so live stdout lives on +// `data.content` / `data.rawOutput`. Measure that too, otherwise coalescing never +// sees growth and in-progress output is held until completed/failed. +export function toolCallProgressLength(state: AcpToolCallState): number { + let contentChars = 0; + const content = state.data.content; + if (Array.isArray(content)) { + for (const entry of content) { + if (!isRecord(entry)) { + continue; + } + const text = toolCallContentText(entry as EffectAcpSchema.ToolCallContent); + if (text) { + contentChars += text.length; + } + } + } + let rawOutputChars = 0; + const rawOutput = state.data.rawOutput; + if (isRecord(rawOutput)) { + for (const field of RAW_OUTPUT_TEXT_FIELDS) { + const value = rawOutput[field]; + if (typeof value === "string") { + rawOutputChars += value.length; + } + } + } + return Math.max(state.detail?.length ?? 0, contentChars, rawOutputChars); +} + +export function decideToolCallUpdateEmission( + input: AcpToolCallEmitDecisionInput, +): AcpToolCallEmitDecision { + const { previous, next, lastEmittedDetailLength, skippedSinceEmit } = input; + if (next.status === "completed" || next.status === "failed") { + return { emit: true, skippedSinceEmit: 0 }; + } + if (previous === undefined || previous.title !== next.title || previous.status !== next.status) { + return { emit: true, skippedSinceEmit: 0 }; + } + if (previous.detail === next.detail && toolCallOutputUnchanged(previous, next)) { + return { emit: false, skippedSinceEmit }; + } + const progressLength = toolCallProgressLength(next); + const grewMeaningfully = + lastEmittedDetailLength === undefined || + Math.abs(progressLength - lastEmittedDetailLength) >= TOOL_CALL_UPDATE_MIN_DETAIL_GROWTH_CHARS; + if (grewMeaningfully || skippedSinceEmit + 1 >= TOOL_CALL_UPDATE_COALESCE_LIMIT) { + return { emit: true, skippedSinceEmit: 0 }; + } + return { emit: false, skippedSinceEmit: skippedSinceEmit + 1 }; +} + export function parsePermissionRequest( params: EffectAcpSchema.RequestPermissionRequest, ): AcpPermissionRequest { @@ -505,6 +731,33 @@ export function syntheticLoadSessionResponseFromInitialize( }; } +// The parsed AcpToolCallState already carries bounded content (see makeToolCallState / +// extractTextContentFromToolCallContent above), but the raw JSON-RPC notification is also +// threaded through as `rawPayload` for logging/debugging and ends up persisted on the +// runtime event. Substitute the same bounded `content`/`rawOutput` there so an oversized +// cumulative update cannot smuggle the unbounded buffer back in through the raw payload. +function boundToolCallRawPayload( + params: EffectAcpSchema.SessionNotification, + update: AcpToolCallUpdate, + toolCall: AcpToolCallState, +): unknown { + const boundedContent = toolCall.data.content; + const boundedRawOutput = toolCall.data.rawOutput; + const contentBounded = update.content !== undefined && boundedContent !== update.content; + const rawOutputBounded = update.rawOutput !== undefined && boundedRawOutput !== update.rawOutput; + if (!contentBounded && !rawOutputBounded) { + return params; + } + return { + ...params, + update: { + ...update, + ...(contentBounded ? { content: boundedContent } : {}), + ...(rawOutputBounded ? { rawOutput: boundedRawOutput } : {}), + }, + }; +} + export function parseSessionUpdateEvent(params: EffectAcpSchema.SessionNotification): { readonly modeId?: string; readonly events: ReadonlyArray; @@ -548,7 +801,7 @@ export function parseSessionUpdateEvent(params: EffectAcpSchema.SessionNotificat events.push({ _tag: "ToolCallUpdated", toolCall, - rawPayload: params, + rawPayload: boundToolCallRawPayload(params, upd, toolCall), }); } break; @@ -559,7 +812,7 @@ export function parseSessionUpdateEvent(params: EffectAcpSchema.SessionNotificat events.push({ _tag: "ToolCallUpdated", toolCall, - rawPayload: params, + rawPayload: boundToolCallRawPayload(params, upd, toolCall), }); } break; diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.ts b/apps/server/src/provider/acp/AcpSessionRuntime.ts index 09fce6d56f9d..2a4cb6a2337b 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.ts @@ -23,9 +23,11 @@ import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { collectSessionConfigOptionValues, + decideToolCallUpdateEmission, extractModelConfigId, findSessionConfigOption, mergeToolCallState, + toolCallProgressLength, parseSessionModeState, parseSessionUpdateEvent, sessionUpdateIsReplay, @@ -36,6 +38,12 @@ import { type AcpToolCallState, } from "./AcpRuntimeModel.ts"; +interface AcpToolCallTrackedState { + readonly state: AcpToolCallState; + readonly lastEmittedDetailLength: number | undefined; + readonly skippedSinceEmit: number; +} + function formatConfigOptionValue(value: string | boolean): string { return JSON.stringify(value); } @@ -226,6 +234,7 @@ export class AcpSessionRuntime extends Context.Service< */ readonly setSessionModel: ( modelId: string, + meta?: EffectAcpSchema.SetSessionModelRequest["_meta"], ) => Effect.Effect; /** * Sends a generic ACP extension request and records it through the request logger. @@ -279,7 +288,7 @@ export const make = ( const runtimeScope = yield* Scope.Scope; const eventQueue = yield* Queue.unbounded(); const modeStateRef = yield* Ref.make(undefined); - const toolCallsRef = yield* Ref.make(new Map()); + const toolCallsRef = yield* Ref.make(new Map()); const assistantItemRuntimeId = yield* crypto.randomUUIDv4.pipe( Effect.mapError( (cause) => @@ -789,12 +798,13 @@ export const make = ( Effect.flatMap((started) => setConfigOption(started.modelConfigId ?? "model", model)), Effect.asVoid, ), - setSessionModel: (modelId) => + setSessionModel: (modelId, meta) => getStartedState.pipe( Effect.flatMap((started) => { const requestPayload = { sessionId: started.sessionId, modelId, + ...(meta !== undefined ? { _meta: meta } : {}), } satisfies EffectAcpSchema.SetSessionModelRequest; return runLoggedRequest( "session/set_model", @@ -851,7 +861,7 @@ const handleSessionUpdate = ({ }: { readonly queue: Queue.Queue; readonly modeStateRef: Ref.Ref; - readonly toolCallsRef: Ref.Ref>; + readonly toolCallsRef: Ref.Ref>; readonly assistantSegmentRef: Ref.Ref; readonly assistantItemRuntimeId: string; readonly params: EffectAcpSchema.SessionNotification; @@ -869,18 +879,31 @@ const handleSessionUpdate = ({ queue, assistantSegmentRef, }); - const { previous, merged } = yield* Ref.modify(toolCallsRef, (current) => { - const previous = current.get(event.toolCall.toolCallId); + const { merged, decision } = yield* Ref.modify(toolCallsRef, (current) => { + const tracked = current.get(event.toolCall.toolCallId); + const previous = tracked?.state; const nextToolCall = mergeToolCallState(previous, event.toolCall); + const decision = decideToolCallUpdateEmission({ + previous, + next: nextToolCall, + lastEmittedDetailLength: tracked?.lastEmittedDetailLength, + skippedSinceEmit: tracked?.skippedSinceEmit ?? 0, + }); const next = new Map(current); if (nextToolCall.status === "completed" || nextToolCall.status === "failed") { next.delete(nextToolCall.toolCallId); } else { - next.set(nextToolCall.toolCallId, nextToolCall); + next.set(nextToolCall.toolCallId, { + state: nextToolCall, + lastEmittedDetailLength: decision.emit + ? toolCallProgressLength(nextToolCall) + : tracked?.lastEmittedDetailLength, + skippedSinceEmit: decision.skippedSinceEmit, + }); } - return [{ previous, merged: nextToolCall }, next] as const; + return [{ merged: nextToolCall, decision }, next] as const; }); - if (!shouldEmitToolCallUpdate(previous, merged)) { + if (!decision.emit) { continue; } yield* Queue.offer(queue, { @@ -926,19 +949,6 @@ function updateModeState(modeState: AcpSessionModeState, nextModeId: string): Ac : modeState; } -function shouldEmitToolCallUpdate( - previous: AcpToolCallState | undefined, - next: AcpToolCallState, -): boolean { - if (next.status === "completed" || next.status === "failed") { - return true; - } - if (!next.detail) { - return false; - } - return previous === undefined || previous.title !== next.title || previous.detail !== next.detail; -} - const assistantItemId = (sessionId: string, runtimeId: string, segmentIndex: number) => `assistant:${sessionId}:runtime:${runtimeId}:segment:${segmentIndex}`; diff --git a/apps/server/src/provider/acp/GrokAcpCliProbe.test.ts b/apps/server/src/provider/acp/GrokAcpCliProbe.test.ts index 222fc4a12d5b..ad6eaac1b521 100644 --- a/apps/server/src/provider/acp/GrokAcpCliProbe.test.ts +++ b/apps/server/src/provider/acp/GrokAcpCliProbe.test.ts @@ -1,6 +1,7 @@ /** * Optional integration check against a real `grok agent stdio` install. - * Enable with: T3_GROK_ACP_PROBE=1 bun run test GrokAcpCliProbe + * Enable with: T3_GROK_ACP_PROBE=1 vp test run GrokAcpCliProbe + * Set T3_GROK_LIVE_TURN=1 to also send a small prompt to the real model. * * The probe assumes either `XAI_API_KEY` is set in the environment or * the user has previously run `grok login`. Without credentials the @@ -10,6 +11,10 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; import * as Effect from "effect/Effect"; +import * as Deferred from "effect/Deferred"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Stream from "effect/Stream"; import { ChildProcessSpawner } from "effect/unstable/process"; import { describe, expect } from "vite-plus/test"; @@ -66,4 +71,60 @@ describe.runIf(process.env.T3_GROK_ACP_PROBE === "1")("Grok ACP CLI probe", () = yield* runtime.setSessionModel(currentModelId); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); + + it.effect("session/set_model accepts advertised reasoning effort metadata", () => + Effect.gen(function* () { + const runtime = yield* makeProbeRuntime; + const started = yield* runtime.start(); + const modelState = started.sessionSetupResult.models; + const currentModelId = modelState?.currentModelId.trim(); + expect(currentModelId).toBeDefined(); + if (!currentModelId) return; + + const currentModel = modelState?.availableModels.find( + (model) => model.modelId.trim() === currentModelId, + ); + const reasoningEffort = currentModel?._meta?.reasoningEffort; + expect(typeof reasoningEffort).toBe("string"); + if (typeof reasoningEffort !== "string") return; + + yield* runtime.setSessionModel(currentModelId, { reasoningEffort }); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect.skipIf(process.env.T3_GROK_LIVE_TURN !== "1")( + "finishes a real Grok turn and streams its answer", + () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const cwd = yield* fileSystem.makeTempDirectoryScoped(); + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const runtime = yield* makeGrokAcpRuntime({ + grokSettings: { binaryPath: "grok" }, + environment: process.env, + childProcessSpawner, + cwd, + runtimeMode: "approval-required", + clientInfo: { name: "t3-grok-probe", version: "0.0.0" }, + }); + yield* runtime.start(); + const chunks: string[] = []; + const events = yield* Stream.runForEach(runtime.getEvents(), (event) => { + if (event._tag === "EventStreamBarrier") { + return Deferred.succeed(event.acknowledge, undefined); + } + if (event._tag === "ContentDelta") { + chunks.push(event.text); + } + return Effect.void; + }).pipe(Effect.forkChild); + const result = yield* runtime.prompt({ + prompt: [{ type: "text", text: "Reply exactly GROK_T3_OK. Do not use any tools." }], + }); + yield* runtime.drainEvents; + expect(result.stopReason).toBe("end_turn"); + expect(chunks.join("")).toContain("GROK_T3_OK"); + yield* Fiber.interrupt(events); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); }); diff --git a/apps/server/src/provider/acp/GrokAcpSupport.test.ts b/apps/server/src/provider/acp/GrokAcpSupport.test.ts index 02d60976b24c..a85f7174a1c0 100644 --- a/apps/server/src/provider/acp/GrokAcpSupport.test.ts +++ b/apps/server/src/provider/acp/GrokAcpSupport.test.ts @@ -5,6 +5,8 @@ import * as EffectAcpErrors from "effect-acp/errors"; import { applyGrokAcpModelSelection, buildGrokAcpSpawnInput, + grokAcpSpawnArgs, + isValidGrokReasoningEffortToken, resolveGrokAcpBaseModelId, } from "./GrokAcpSupport.ts"; @@ -16,6 +18,35 @@ describe("resolveGrokAcpBaseModelId", () => { }); }); +describe("grokAcpSpawnArgs", () => { + it("inherits the Grok CLI config when no T3 runtime mode is set", () => { + expect(grokAcpSpawnArgs()).toEqual(["agent", "stdio"]); + }); + + it("forces Grok to ask when T3 is Supervised", () => { + expect(grokAcpSpawnArgs("approval-required")).toEqual([ + "--permission-mode", + "default", + "agent", + "stdio", + ]); + }); + + it("maps Full access to Grok always-approve", () => { + expect(grokAcpSpawnArgs("full-access")).toEqual(["agent", "--always-approve", "stdio"]); + }); + + it("maps Auto-accept edits and Auto onto Grok permission modes", () => { + expect(grokAcpSpawnArgs("auto-accept-edits")).toEqual([ + "--permission-mode", + "acceptEdits", + "agent", + "stdio", + ]); + expect(grokAcpSpawnArgs("auto")).toEqual(["--permission-mode", "auto", "agent", "stdio"]); + }); +}); + describe("buildGrokAcpSpawnInput", () => { it("passes the T3 Code referrer through Grok OAuth env", () => { const spawn = buildGrokAcpSpawnInput({ binaryPath: "/usr/local/bin/grok" }, "/tmp/project", { @@ -33,15 +64,38 @@ describe("buildGrokAcpSpawnInput", () => { }, }); }); + + it("puts Supervised on the Grok argv so config always-approve cannot win", () => { + const spawn = buildGrokAcpSpawnInput( + { binaryPath: "/usr/local/bin/grok" }, + "/tmp/project", + undefined, + "approval-required", + ); + expect(spawn.args).toEqual(["--permission-mode", "default", "agent", "stdio"]); + }); +}); + +describe("isValidGrokReasoningEffortToken", () => { + it("accepts future ACP tokens and rejects malformed metadata values", () => { + expect(isValidGrokReasoningEffortToken("xhigh")).toBe(true); + expect(isValidGrokReasoningEffortToken("turbo_v2")).toBe(true); + expect(isValidGrokReasoningEffortToken("not a token")).toBe(false); + expect(isValidGrokReasoningEffortToken("-leading-dash")).toBe(false); + expect(isValidGrokReasoningEffortToken("x".repeat(33))).toBe(false); + }); }); describe("applyGrokAcpModelSelection", () => { const makeRecordingRuntime = (failure?: EffectAcpErrors.AcpError) => { - const modelCalls: Array = []; + const modelCalls: Array<{ + modelId: string; + meta?: { readonly [key: string]: unknown } | null; + }> = []; const runtime = { - setSessionModel: (modelId: string) => + setSessionModel: (modelId: string, meta?: { readonly [key: string]: unknown } | null) => Effect.gen(function* () { - modelCalls.push(modelId); + modelCalls.push(meta === undefined ? { modelId } : { modelId, meta }); if (failure) return yield* failure; return {}; }), @@ -58,11 +112,58 @@ describe("applyGrokAcpModelSelection", () => { requestedModelId: "grok-mock-alt", mapError: (cause) => cause.message, }); - expect(modelCalls).toEqual(["grok-mock-alt"]); + expect(modelCalls).toEqual([{ modelId: "grok-mock-alt" }]); expect(result).toBe("grok-mock-alt"); }), ); + it.effect("applies reasoning effort through session/set_model metadata", () => + Effect.gen(function* () { + const { runtime, modelCalls } = makeRecordingRuntime(); + const result = yield* applyGrokAcpModelSelection({ + runtime, + currentModelId: "grok-4.6", + currentReasoningEffort: "high", + requestedModelId: "grok-4.6", + requestedReasoningEffort: "xhigh", + mapError: (cause) => cause.message, + }); + expect(modelCalls).toEqual([{ modelId: "grok-4.6", meta: { reasoningEffort: "xhigh" } }]); + expect(result).toBe("grok-4.6"); + }), + ); + + it.effect("does not clear reasoning when same-model selection omits effort", () => + Effect.gen(function* () { + const { runtime, modelCalls } = makeRecordingRuntime(); + const result = yield* applyGrokAcpModelSelection({ + runtime, + currentModelId: "grok-4.6", + currentReasoningEffort: "high", + requestedModelId: "grok-4.6", + requestedReasoningEffort: undefined, + mapError: (cause) => cause.message, + }); + expect(modelCalls).toEqual([]); + expect(result).toBe("grok-4.6"); + }), + ); + + it.effect("drops malformed effort metadata instead of sending it", () => + Effect.gen(function* () { + const { runtime, modelCalls } = makeRecordingRuntime(); + yield* applyGrokAcpModelSelection({ + runtime, + currentModelId: "grok-4.6", + currentReasoningEffort: "high", + requestedModelId: "grok-4.6", + requestedReasoningEffort: "not a token", + mapError: (cause) => cause.message, + }); + expect(modelCalls).toEqual([{ modelId: "grok-4.6" }]); + }), + ); + it.effect("skips set_model when requested matches current", () => Effect.gen(function* () { const { runtime, modelCalls } = makeRecordingRuntime(); diff --git a/apps/server/src/provider/acp/GrokAcpSupport.ts b/apps/server/src/provider/acp/GrokAcpSupport.ts index c928b3ed80e0..001f0adfe7b8 100644 --- a/apps/server/src/provider/acp/GrokAcpSupport.ts +++ b/apps/server/src/provider/acp/GrokAcpSupport.ts @@ -1,4 +1,4 @@ -import { type GrokSettings, ProviderDriverKind } from "@t3tools/contracts"; +import { type GrokSettings, ProviderDriverKind, type RuntimeMode } from "@t3tools/contracts"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -27,16 +27,33 @@ interface GrokAcpRuntimeInput extends Omit< readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; readonly grokSettings: GrokAcpRuntimeGrokSettings | null | undefined; readonly environment?: NodeJS.ProcessEnv; + readonly runtimeMode?: RuntimeMode; +} + +export function grokAcpSpawnArgs(runtimeMode?: RuntimeMode): ReadonlyArray { + switch (runtimeMode) { + case "approval-required": + return ["--permission-mode", "default", "agent", "stdio"]; + case "auto-accept-edits": + return ["--permission-mode", "acceptEdits", "agent", "stdio"]; + case "auto": + return ["--permission-mode", "auto", "agent", "stdio"]; + case "full-access": + return ["agent", "--always-approve", "stdio"]; + default: + return ["agent", "stdio"]; + } } export function buildGrokAcpSpawnInput( grokSettings: GrokAcpRuntimeGrokSettings | null | undefined, cwd: string, environment?: NodeJS.ProcessEnv, + runtimeMode?: RuntimeMode, ): AcpSessionRuntime.AcpSpawnInput { return { command: grokSettings?.binaryPath || "grok", - args: ["agent", "stdio"], + args: [...grokAcpSpawnArgs(runtimeMode)], cwd, env: { ...environment, @@ -62,7 +79,12 @@ export const makeGrokAcpRuntime = ( const acpContext = yield* Layer.build( AcpSessionRuntime.layer({ ...input, - spawn: buildGrokAcpSpawnInput(input.grokSettings, input.cwd, input.environment), + spawn: buildGrokAcpSpawnInput( + input.grokSettings, + input.cwd, + input.environment, + input.runtimeMode, + ), authMethodId: resolveGrokAuthMethodId(input.environment), }).pipe( Layer.provide( @@ -82,6 +104,17 @@ export function resolveGrokAcpBaseModelId(model: string | null | undefined): str return normalizeModelSlug(base, GROK_DRIVER_KIND) ?? "grok-build"; } +const GROK_REASONING_EFFORT_TOKEN = /^[a-z0-9][a-z0-9._-]{0,31}$/i; + +export function isValidGrokReasoningEffortToken(value: string): boolean { + return GROK_REASONING_EFFORT_TOKEN.test(value); +} + +export function normalizeGrokReasoningEffort(value: string | undefined): string | undefined { + const effort = value?.trim(); + return effort && isValidGrokReasoningEffortToken(effort) ? effort : undefined; +} + export function currentGrokModelIdFromSessionSetup( sessionSetupResult: | EffectAcpSchema.LoadSessionResponse @@ -91,18 +124,57 @@ export function currentGrokModelIdFromSessionSetup( return sessionSetupResult.models?.currentModelId?.trim() || undefined; } +export function currentGrokReasoningEffortFromSessionSetup( + sessionSetupResult: + | EffectAcpSchema.LoadSessionResponse + | EffectAcpSchema.NewSessionResponse + | EffectAcpSchema.ResumeSessionResponse, +): string | undefined { + const modelState = sessionSetupResult.models; + if (!modelState) { + return undefined; + } + const currentModelId = modelState.currentModelId.trim(); + if (currentModelId.length === 0) { + return undefined; + } + const currentModel = modelState.availableModels.find( + (model) => model.modelId.trim() === currentModelId, + ); + const reasoningEffort = currentModel?._meta?.reasoningEffort; + return typeof reasoningEffort === "string" + ? normalizeGrokReasoningEffort(reasoningEffort) + : undefined; +} + export function applyGrokAcpModelSelection(input: { readonly runtime: Pick; readonly currentModelId: string | undefined; + readonly currentReasoningEffort?: string | undefined; readonly requestedModelId: string | undefined; + readonly requestedReasoningEffort?: string | undefined; readonly mapError: (cause: EffectAcpErrors.AcpError) => E; }): Effect.Effect { - const shouldSwitchModel = + const modelChanged = input.requestedModelId !== undefined && input.requestedModelId !== input.currentModelId; - if (!shouldSwitchModel) { + const reasoningProvided = input.requestedReasoningEffort !== undefined; + const reasoningEffort = reasoningProvided + ? normalizeGrokReasoningEffort(input.requestedReasoningEffort) + : undefined; + const reasoningEffortChanged = + reasoningProvided && reasoningEffort !== input.currentReasoningEffort; + const targetModelId = input.requestedModelId ?? input.currentModelId; + if ((!modelChanged && !reasoningEffortChanged) || targetModelId === undefined) { return Effect.succeed(input.currentModelId); } + const reasoningMeta = + reasoningProvided && reasoningEffort !== undefined ? { reasoningEffort } : undefined; + // When reasoning was explicitly provided but invalid (normalize => undefined), we deliberately + // send no meta so the invalid value is dropped rather than forwarded. When reasoning was not + // provided at all, we also send no meta, but we only reach this call when the model itself + // changed - an omitted reasoning preference must not be treated as an explicit clear of the + // CLI-advertised default (e.g. Extra High) on same-model reselections. return input.runtime - .setSessionModel(input.requestedModelId) - .pipe(Effect.mapError(input.mapError), Effect.as(input.requestedModelId)); + .setSessionModel(targetModelId, reasoningMeta) + .pipe(Effect.mapError(input.mapError), Effect.as(targetModelId)); } diff --git a/apps/server/src/provider/acp/XAiAcpExtension.test.ts b/apps/server/src/provider/acp/XAiAcpExtension.test.ts index c435269fd76d..28f5f29f4987 100644 --- a/apps/server/src/provider/acp/XAiAcpExtension.test.ts +++ b/apps/server/src/provider/acp/XAiAcpExtension.test.ts @@ -1,4 +1,5 @@ // @effect-diagnostics nodeBuiltinImport:off +import * as NodeOS from "node:os"; import * as NodePath from "node:path"; import * as NodeURL from "node:url"; @@ -9,11 +10,17 @@ import * as Schema from "effect/Schema"; import { describe, expect } from "vite-plus/test"; import { + extractGrokPlanMarkdownFromToolCallData, extractXAiAskUserQuestions, + extractXAiExitPlanMarkdown, + isGrokPlanMarkdownPath, makeXAiAskUserQuestionCancelledResponse, makeXAiAskUserQuestionResponse, + makeXAiExitPlanModeCapturedResponse, makeXAiPromptCompletionRuntime, + XAI_EMPTY_PLAN_MARKDOWN, XAiAskUserQuestionRequest, + XAiExitPlanModeRequest, } from "./XAiAcpExtension.ts"; import * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; @@ -299,6 +306,27 @@ describe("XAiAcpExtension", () => { }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); + it.effect("fails a hung standard prompt from an xAI rate-limit completion", () => + Effect.gen(function* () { + const runtime = yield* makePromptCompletionRuntime({ + T3_ACP_EMIT_XAI_RATE_LIMIT_THEN_HANG: "1", + }); + yield* runtime.start(); + + const error = yield* Effect.flip( + runtime.prompt({ + prompt: [{ type: "text", text: "hi" }], + }), + ); + + expect(error).toMatchObject({ + _tag: "AcpRequestError", + code: -32003, + errorMessage: "Grok usage limit reached. Try again later.", + }); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + it.effect("ignores stale xAI completion from an already settled prompt", () => Effect.gen(function* () { const runtime = yield* makePromptCompletionRuntime({ @@ -329,4 +357,170 @@ describe("XAiAcpExtension", () => { }); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); + + it("extracts plan markdown from exit_plan_mode payloads", () => { + const decode = Schema.decodeUnknownSync(XAiExitPlanModeRequest); + const direct = decode({ + sessionId: "session-1", + toolCallId: "exit-1", + planContent: "# Plan\n\n- do the thing\n", + }); + expect(extractXAiExitPlanMarkdown(direct)).toBe("# Plan\n\n- do the thing"); + + const wrapped = decode({ + method: "_x.ai/exit_plan_mode", + params: { + sessionId: "session-1", + toolCallId: "exit-1", + planContent: null, + }, + }); + expect(extractXAiExitPlanMarkdown(wrapped, " # fallback plan ")).toBe("# fallback plan"); + expect(extractXAiExitPlanMarkdown(wrapped, "")).toBe(XAI_EMPTY_PLAN_MARKDOWN); + expect(extractXAiExitPlanMarkdown(wrapped)).toBe(XAI_EMPTY_PLAN_MARKDOWN); + }); + + it("builds an abandoned exit_plan_mode response that captures the plan", () => { + expect(makeXAiExitPlanModeCapturedResponse()).toEqual({ + outcome: "abandoned", + feedback: + "The client captured your proposed plan. Stop here and wait for the user's feedback or implementation request in a later turn.", + }); + }); + + it("identifies Grok plan.md paths and extracts markdown from tool call data", () => { + const linuxHost = { platform: "linux" as const, environment: {} }; + const windowsHost = { platform: "win32" as const, environment: {} }; + const grokHomeHost = { + platform: "linux" as const, + environment: { GROK_HOME: "/opt/grok-data" }, + }; + const home = NodeOS.homedir().replace(/\\/g, "/"); + const sessionPlan = `${home}/.grok/sessions/abc/plan.md`; + const nestedSessionPlan = `${home}/.grok/sessions/%2Fhome%2Fproj/019fd20e-c563-70a0-b801-a6bc51815a9b/plan.md`; + expect(isGrokPlanMarkdownPath(sessionPlan, linuxHost)).toBe(true); + expect(isGrokPlanMarkdownPath(nestedSessionPlan, linuxHost)).toBe(true); + expect(isGrokPlanMarkdownPath("~/.grok/sessions/abc/plan.md", linuxHost)).toBe(true); + expect(isGrokPlanMarkdownPath("/tmp/mock-home/.grok/sessions/sess/plan.md", linuxHost)).toBe( + true, + ); + expect(isGrokPlanMarkdownPath("/home/other/.grok/sessions/sess/plan.md", linuxHost)).toBe(true); + expect(isGrokPlanMarkdownPath("/HOME/other/.grok/sessions/sess/plan.md", linuxHost)).toBe( + false, + ); + expect(isGrokPlanMarkdownPath("C:/Users/other/.grok/sessions/id/plan.md", windowsHost)).toBe( + true, + ); + expect(isGrokPlanMarkdownPath("c:/users/OTHER/.GROK/SESSIONS/id/PLAN.MD", windowsHost)).toBe( + true, + ); + expect( + isGrokPlanMarkdownPath("C:\\Users\\other\\.grok\\sessions\\id\\plan.md", windowsHost), + ).toBe(true); + expect(isGrokPlanMarkdownPath("/opt/grok-data/sessions/sess/plan.md", grokHomeHost)).toBe(true); + expect( + isGrokPlanMarkdownPath("/OPT/GROK-DATA/sessions/sess/plan.md", { + platform: "win32", + environment: { GROK_HOME: "/opt/grok-data" }, + }), + ).toBe(true); + expect(isGrokPlanMarkdownPath("/OPT/GROK-DATA/sessions/sess/plan.md", grokHomeHost)).toBe( + false, + ); + // Workspace plan.md must not be treated as the session plan file. + expect(isGrokPlanMarkdownPath("plan.md", linuxHost)).toBe(false); + expect(isGrokPlanMarkdownPath("/repo/docs/plan.md", linuxHost)).toBe(false); + expect(isGrokPlanMarkdownPath("/tmp/other.md", linuxHost)).toBe(false); + expect(isGrokPlanMarkdownPath("/repo/.grok/sessions/example/plan.md", linuxHost)).toBe(false); + expect( + isGrokPlanMarkdownPath(`${home}/project/.grok/sessions/example/plan.md`, linuxHost), + ).toBe(false); + expect( + isGrokPlanMarkdownPath("/home/other/.grok/sessions/../../project/plan.md", linuxHost), + ).toBe(false); + expect( + isGrokPlanMarkdownPath("/home/other/.grok/sessions/foo/../../../project/plan.md", linuxHost), + ).toBe(false); + + expect( + extractGrokPlanMarkdownFromToolCallData( + { + rawInput: { + file_path: sessionPlan, + content: "# From rawInput\n\n- a\n", + }, + }, + linuxHost, + ), + ).toBe("# From rawInput\n\n- a"); + + expect( + extractGrokPlanMarkdownFromToolCallData( + { + content: [ + { + type: "diff", + path: sessionPlan, + oldText: "", + newText: "# From diff\n\n- b\n", + }, + ], + }, + linuxHost, + ), + ).toBe("# From diff\n\n- b"); + + expect( + extractGrokPlanMarkdownFromToolCallData( + { + rawInput: { file_path: sessionPlan, content: "" }, + content: [ + { + type: "diff", + path: sessionPlan, + oldText: "", + newText: "# From diff after empty rawInput\n", + }, + ], + }, + linuxHost, + ), + ).toBe("# From diff after empty rawInput"); + + expect( + extractGrokPlanMarkdownFromToolCallData( + { + rawInput: { file_path: sessionPlan, content: "" }, + }, + linuxHost, + ), + ).toBe(""); + + expect( + extractGrokPlanMarkdownFromToolCallData( + { + content: [{ type: "diff", path: sessionPlan, oldText: "# old", newText: "" }], + }, + linuxHost, + ), + ).toBe(""); + + expect( + extractGrokPlanMarkdownFromToolCallData( + { + rawInput: { file_path: "/tmp/readme.md", content: "nope" }, + }, + linuxHost, + ), + ).toBeUndefined(); + + expect( + extractGrokPlanMarkdownFromToolCallData( + { + rawInput: { file_path: "/repo/docs/plan.md", content: "# Project plan\n" }, + }, + linuxHost, + ), + ).toBeUndefined(); + }); }); diff --git a/apps/server/src/provider/acp/XAiAcpExtension.ts b/apps/server/src/provider/acp/XAiAcpExtension.ts index d36a5fcfc895..543edb39bb6d 100644 --- a/apps/server/src/provider/acp/XAiAcpExtension.ts +++ b/apps/server/src/provider/acp/XAiAcpExtension.ts @@ -1,8 +1,11 @@ +import * as NodeOS from "node:os"; + import type { ProviderUserInputAnswers, UserInputQuestion } from "@t3tools/contracts"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; +import * as EffectAcpErrors from "effect-acp/errors"; import type * as EffectAcpSchema from "effect-acp/schema"; import type * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; @@ -19,11 +22,15 @@ type XAiPromptCompleteNotification = typeof XAiPromptCompleteNotification.Type; interface PendingXAiPromptCompletion { readonly sessionId: string; readonly promptId: string; - readonly deferred: Deferred.Deferred; + readonly deferred: Deferred.Deferred< + EffectAcpSchema.PromptResponse, + EffectAcpErrors.AcpRequestError + >; } const completedXAiPromptIdLimit = 128; const xAiStopReasonMissingMetaKey = "xAiStopReasonMissing"; +const xAiRateLimitedErrorCode = -32003; const XAiAskUserQuestionOption = Schema.Struct({ label: Schema.String, @@ -196,6 +203,218 @@ export function makeXAiAskUserQuestionCancelledResponse(): XAiAskUserQuestionCan return { outcome: "cancelled" }; } +// --------------------------------------------------------------------------- +// x.ai/exit_plan_mode — plan approval gate (mirrors Grok Build TUI plan window) +// --------------------------------------------------------------------------- + +const XAiExitPlanModeParams = Schema.Struct({ + sessionId: Schema.String, + toolCallId: Schema.String, + planContent: Schema.optional(Schema.NullOr(Schema.String)), +}); + +const XAiWrappedExitPlanModeParams = Schema.Struct({ + method: Schema.Literals(["x.ai/exit_plan_mode", "_x.ai/exit_plan_mode"]), + params: XAiExitPlanModeParams, +}); + +export const XAiExitPlanModeRequest = Schema.Union([ + XAiExitPlanModeParams, + XAiWrappedExitPlanModeParams, +]); + +type XAiExitPlanModeRequestParams = typeof XAiExitPlanModeParams.Type; +type XAiExitPlanModeRequest = typeof XAiExitPlanModeRequest.Type; + +function unwrapExitPlanModeParams(params: XAiExitPlanModeRequest): XAiExitPlanModeRequestParams { + return "params" in params ? params.params : params; +} + +/** Empty-state copy when Grok exits plan mode without a plan file. */ +export const XAI_EMPTY_PLAN_MARKDOWN = + "# No plan written yet\n\n(The agent exited plan mode without writing a plan.)"; + +export function extractXAiExitPlanMarkdown( + params: XAiExitPlanModeRequest, + fallback?: string | null, +): string { + const content = unwrapExitPlanModeParams(params).planContent; + const fromRequest = typeof content === "string" ? trimmed(content) : undefined; + if (fromRequest) { + return fromRequest; + } + const fromFallback = fallback?.trim(); + if (fromFallback && fromFallback.length > 0) { + return fromFallback; + } + return XAI_EMPTY_PLAN_MARKDOWN; +} + +export type XAiExitPlanModeOutcome = "approved" | "abandoned" | "request_changes"; + +export interface XAiExitPlanModeResponse { + readonly outcome: XAiExitPlanModeOutcome; + readonly feedback?: string; +} + +/** + * Client captured the plan for T3's proposed-plan card. Abandon the native + * Grok plan-approval gate so the turn unblocks; the user implements via T3 UI. + */ +export function makeXAiExitPlanModeCapturedResponse(feedback?: string): XAiExitPlanModeResponse { + return { + outcome: "abandoned", + feedback: + feedback ?? + "The client captured your proposed plan. Stop here and wait for the user's feedback or implementation request in a later turn.", + }; +} + +function normalizeFsPath(value: string): string { + return value.trim().replace(/\\/g, "/").replace(/\/+$/, ""); +} + +function pathHasTraversalSegment(normalized: string): boolean { + return normalized.split("/").includes(".."); +} + +function addGrokSessionPrefix( + prefixes: Set, + homeOrRoot: string, + nestedGrokDir: boolean, +): void { + const root = normalizeFsPath(homeOrRoot); + if (!root) { + return; + } + prefixes.add(nestedGrokDir ? `${root}/.grok/sessions/` : `${root}/sessions/`); +} + +/** Injected host bits so these helpers stay off `process.platform` / `process.env`. */ +export interface GrokPlanPathHost { + readonly platform: NodeJS.Platform; + readonly environment: NodeJS.ProcessEnv; +} + +function grokPlanSessionPrefixes(environment: NodeJS.ProcessEnv): ReadonlySet { + const prefixes = new Set(); + addGrokSessionPrefix(prefixes, NodeOS.homedir(), true); + addGrokSessionPrefix(prefixes, "~", true); + addGrokSessionPrefix(prefixes, environment.HOME ?? "", true); + addGrokSessionPrefix(prefixes, environment.USERPROFILE ?? "", true); + // ACP mock and isolated Grok spawns use a HOME that is not the server process home. + addGrokSessionPrefix(prefixes, "/tmp/mock-home", true); + const grokHome = environment.GROK_HOME ?? ""; + addGrokSessionPrefix(prefixes, grokHome, false); + addGrokSessionPrefix(prefixes, grokHome, true); + return prefixes; +} + +const CANONICAL_HOME_GROK_SESSION_PATH = + /^(?:\/home\/[^/]+|\/Users\/[^/]+|[a-zA-Z]:\/Users\/[^/]+)\/\.grok\/sessions\/(?:[^/]+\/)+plan\.md$/; +const CASE_INSENSITIVE_CANONICAL_HOME_GROK_SESSION_PATH = new RegExp( + CANONICAL_HOME_GROK_SESSION_PATH.source, + "i", +); + +/** + * True when a path is Grok's session plan file under a Grok home + * (`~/.grok/sessions/.../plan.md`, `$HOME/.grok/sessions/...`, or `$GROK_HOME/sessions/...`). + * Deliberately does not match workspace files named `plan.md` (e.g. docs/plan.md + * or a repo-local `.grok/sessions/.../plan.md`). + */ +export function isGrokPlanMarkdownPath( + path: string | undefined | null, + host: GrokPlanPathHost, +): boolean { + if (typeof path !== "string") { + return false; + } + const normalized = path.trim().replace(/\\/g, "/"); + const win32 = host.platform === "win32"; + const haystack = win32 ? normalized.toLowerCase() : normalized; + if ( + normalized.length === 0 || + !haystack.endsWith("/plan.md") || + pathHasTraversalSegment(normalized) + ) { + return false; + } + for (const prefix of grokPlanSessionPrefixes(host.environment)) { + const needle = win32 ? prefix.toLowerCase() : prefix; + if (!haystack.startsWith(needle)) { + continue; + } + const rest = haystack.slice(needle.length); + // Session layout: /.grok/sessions///plan.md + if (rest !== "plan.md" && rest.endsWith("plan.md")) { + return true; + } + } + return ( + win32 ? CASE_INSENSITIVE_CANONICAL_HOME_GROK_SESSION_PATH : CANONICAL_HOME_GROK_SESSION_PATH + ).test(haystack); +} + +/** + * Extract plan markdown from a Grok write/edit tool call targeting plan.md. + * Used so T3 can show the plan while plan mode is still active (before exit). + */ +export function extractGrokPlanMarkdownFromToolCallData( + data: Record | undefined, + host: GrokPlanPathHost, +): string | undefined { + if (!data) { + return undefined; + } + + let sawPlanWrite = false; + const takePlanText = ( + value: string | undefined, + filePath: string | undefined, + ): string | undefined => { + if (!isGrokPlanMarkdownPath(filePath, host) || value === undefined) { + return undefined; + } + sawPlanWrite = true; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; + }; + + const rawInput = data.rawInput; + if (isRecord(rawInput)) { + const filePath = + (typeof rawInput.file_path === "string" ? rawInput.file_path : undefined) ?? + (typeof rawInput.path === "string" ? rawInput.path : undefined); + const content = typeof rawInput.content === "string" ? rawInput.content : undefined; + const fromRaw = takePlanText(content, filePath); + if (fromRaw !== undefined) { + return fromRaw; + } + } + + const content = data.content; + if (Array.isArray(content)) { + for (const block of content) { + if (!isRecord(block) || block.type !== "diff") { + continue; + } + const path = typeof block.path === "string" ? block.path : undefined; + const newText = typeof block.newText === "string" ? block.newText : undefined; + const fromDiff = takePlanText(newText, path); + if (fromDiff !== undefined) { + return fromDiff; + } + } + } + + return sawPlanWrite ? "" : undefined; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + /** * Adds Grok's private prompt-completion fallback around a standards-only ACP runtime. * The underlying runtime remains unaware of xAI methods and metadata. @@ -278,7 +497,7 @@ const registerXAiPromptCompletionFallback = ( sessionId: string, promptId: string, ) => - Deferred.make().pipe( + Deferred.make().pipe( Effect.tap((deferred) => Ref.update(pendingRef, (pending) => [...pending, { sessionId, promptId, deferred }]), ), @@ -287,7 +506,7 @@ const registerXAiPromptCompletionFallback = ( const unregisterXAiPromptCompletionFallback = ( pendingRef: Ref.Ref>, - deferred: Deferred.Deferred, + deferred: Deferred.Deferred, ) => Ref.update(pendingRef, (pending) => pending.filter((entry) => entry.deferred !== deferred)); const abortPendingPromptCompletions = ( @@ -358,13 +577,48 @@ const resolveXAiPromptCompletionFallback = ({ return [Effect.void, pending] as const; } return [ - Deferred.succeed(entry.deferred, promptResponseFromXAi(notification)).pipe(Effect.asVoid), + settleXAiPromptCompletion(entry.deferred, notification), [...pending.slice(0, index), ...pending.slice(index + 1)], ] as const; }).pipe(Effect.flatten); }), ); +const settleXAiPromptCompletion = ( + deferred: Deferred.Deferred, + notification: XAiPromptCompleteNotification, +) => { + if (notification.stopReason === "rate_limit") { + return Deferred.fail( + deferred, + new EffectAcpErrors.AcpRequestError({ + code: xAiRateLimitedErrorCode, + errorMessage: "Grok usage limit reached. Try again later.", + }), + ).pipe(Effect.asVoid); + } + if (notification.stopReason === "error") { + return Deferred.fail( + deferred, + EffectAcpErrors.AcpRequestError.internalError( + xAiAgentResultMessage(notification.agentResult) ?? "Grok prompt failed.", + ), + ).pipe(Effect.asVoid); + } + return Deferred.succeed(deferred, promptResponseFromXAi(notification)).pipe(Effect.asVoid); +}; + +function xAiAgentResultMessage(value: unknown): string | undefined { + if (typeof value === "string") { + return trimmed(value); + } + if (value === null || typeof value !== "object") { + return undefined; + } + const message = "message" in value ? value.message : undefined; + return typeof message === "string" ? trimmed(message) : undefined; +} + const rememberCompletedXAiPromptId = ( completedPromptIdsRef: Ref.Ref>, response: EffectAcpSchema.PromptResponse, diff --git a/apps/server/src/provider/makeManagedServerProvider.test.ts b/apps/server/src/provider/makeManagedServerProvider.test.ts index 5bfd3e14cfd7..fd50fa13eb08 100644 --- a/apps/server/src/provider/makeManagedServerProvider.test.ts +++ b/apps/server/src/provider/makeManagedServerProvider.test.ts @@ -250,6 +250,40 @@ describe("makeManagedServerProvider", () => { ).pipe(Effect.provide(Layer.mergeAll(AlwaysRunTestLayer, TestClock.layer()))), ); + it.effect("keeps manual refresh when interval refresh is disabled", () => + Effect.scoped( + Effect.gen(function* () { + const checkCalls = yield* Ref.make(0); + const initialCheckDone = yield* Deferred.make(); + const provider = yield* makeManagedServerProvider({ + maintenanceCapabilities, + getSettings: Effect.succeed({ enabled: true }), + streamSettings: Stream.empty, + haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled, + initialSnapshot: () => Effect.succeed(initialSnapshot), + checkProvider: Ref.updateAndGet(checkCalls, (count) => count + 1).pipe( + Effect.tap((count) => + count === 1 + ? Deferred.succeed(initialCheckDone, undefined).pipe(Effect.ignore) + : Effect.void, + ), + Effect.as(refreshedSnapshot), + ), + refreshInterval: "1 second", + refreshOnInterval: false, + }); + + yield* Deferred.await(initialCheckDone); + yield* TestClock.adjust("5 minutes"); + yield* Effect.yieldNow; + assert.strictEqual(yield* Ref.get(checkCalls), 1); + + yield* provider.refresh; + assert.strictEqual(yield* Ref.get(checkCalls), 2); + }), + ).pipe(Effect.provide(Layer.mergeAll(AlwaysRunTestLayer, TestClock.layer()))), + ); + it.effect("wakes a sleeping provider refresh loop when its interval changes", () => Effect.scoped( Effect.gen(function* () { @@ -355,6 +389,41 @@ describe("makeManagedServerProvider", () => { ).pipe(Effect.provide(AlwaysRunTestLayer)), ); + it.effect("can update settings and disable periodic checks without probing again", () => + Effect.scoped( + Effect.gen(function* () { + const settingsChanges = yield* PubSub.unbounded(); + const checkCalls = yield* Ref.make(0); + const initialCheckDone = yield* Deferred.make(); + const enrichmentCalls = yield* Ref.make(0); + yield* makeManagedServerProvider({ + maintenanceCapabilities, + getSettings: Effect.succeed({ enabled: true }), + streamSettings: Stream.fromPubSub(settingsChanges), + haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled, + checkProviderOnSettingsChange: () => false, + refreshOnInterval: false, + initialSnapshot: () => Effect.succeed(initialSnapshot), + checkProvider: Ref.updateAndGet(checkCalls, (count) => count + 1).pipe( + Effect.tap(() => Deferred.succeed(initialCheckDone, undefined).pipe(Effect.ignore)), + Effect.as(refreshedSnapshot), + ), + enrichSnapshot: () => Ref.update(enrichmentCalls, (count) => count + 1), + refreshInterval: "1 second", + }); + + yield* Deferred.await(initialCheckDone); + yield* PubSub.publish(settingsChanges, { enabled: false }); + yield* Effect.yieldNow; + yield* TestClock.adjust("1 second"); + yield* Effect.yieldNow; + + assert.strictEqual(yield* Ref.get(checkCalls), 1); + assert.strictEqual(yield* Ref.get(enrichmentCalls), 2); + }), + ).pipe(Effect.provide(Layer.mergeAll(AlwaysRunTestLayer, TestClock.layer()))), + ); + it.effect("streams supplemental snapshot updates after the base provider check completes", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/provider/makeManagedServerProvider.ts b/apps/server/src/provider/makeManagedServerProvider.ts index d2b6b52e8f1c..a009157144c7 100644 --- a/apps/server/src/provider/makeManagedServerProvider.ts +++ b/apps/server/src/provider/makeManagedServerProvider.ts @@ -40,6 +40,8 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( readonly publishSnapshot: (snapshot: ServerProvider) => Effect.Effect; }) => Effect.Effect; readonly refreshInterval?: Duration.Input; + readonly refreshOnInterval?: boolean; + readonly checkProviderOnSettingsChange?: (previous: Settings, next: Settings) => boolean; }): Effect.fn.Return< ServerProviderShape, ServerSettingsError, @@ -121,6 +123,21 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( return yield* Ref.get(snapshotStateRef).pipe(Effect.map((state) => state.snapshot)); } + if ( + !forceRefresh && + input.checkProviderOnSettingsChange?.(previousSettings, nextSettings) === false + ) { + const state = yield* Ref.get(snapshotStateRef); + const nextGeneration = state.enrichmentGeneration + 1; + yield* Ref.set(snapshotStateRef, { + ...state, + enrichmentGeneration: nextGeneration, + }); + yield* Ref.set(settingsRef, nextSettings); + yield* restartSnapshotEnrichment(nextSettings, state.snapshot, nextGeneration); + return state.snapshot; + } + const nextSnapshot = yield* input.checkProvider; const nextGeneration = yield* Ref.modify(snapshotStateRef, (state) => { const generation = input.enrichSnapshot @@ -199,7 +216,9 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( Queue.take(refreshIntervalChanges).pipe(Effect.as(false)), ).pipe( Effect.flatMap((intervalElapsed) => - intervalElapsed && Duration.toMillis(Duration.fromInputUnsafe(refreshInterval)) > 0 + input.refreshOnInterval !== false && + intervalElapsed && + Duration.toMillis(Duration.fromInputUnsafe(refreshInterval)) > 0 ? hasProviderStatusDemand.pipe( Effect.flatMap((shouldRefresh) => shouldRefresh ? refreshSnapshot().pipe(Effect.asVoid) : Effect.void, diff --git a/apps/server/src/provider/model-manifest.json b/apps/server/src/provider/model-manifest.json new file mode 100644 index 000000000000..713015965057 --- /dev/null +++ b/apps/server/src/provider/model-manifest.json @@ -0,0 +1,372 @@ +{ + "version": 1, + "currentModels": { + "codex": [ + "gpt-5.6-luna", + "gpt-5.6-terra", + "gpt-5.6-sol", + "gpt-daybreak-blue-latest", + "gpt-daybreak-red-latest" + ], + "claudeAgent": ["claude-fable-5-1", "claude-opus-5", "claude-sonnet-5"] + }, + "providers": { + "claudeAgent": { + "defaults": { + "chat": "claude-sonnet-5" + }, + "profiles": { + "fable-5": { + "capabilities": { + "optionDescriptors": [ + { + "id": "effort", + "label": "Reasoning", + "type": "select", + "options": [ + { "id": "low", "label": "Low" }, + { "id": "medium", "label": "Medium" }, + { "id": "high", "label": "High", "isDefault": true }, + { "id": "xhigh", "label": "Extra High" }, + { "id": "max", "label": "Max" }, + { + "id": "ultracode", + "label": "Ultracode", + "description": "xhigh effort plus multi-agent workflow orchestration" + }, + { "id": "ultrathink", "label": "Ultrathink" } + ], + "promptInjectedValues": ["ultrathink"] + }, + { + "id": "contextWindow", + "label": "Context Window", + "type": "select", + "options": [ + { "id": "200k", "label": "200k" }, + { "id": "1m", "label": "1M", "isDefault": true } + ] + } + ] + }, + "adapter": { + "claudeCode": { + "effortMap": { "ultracode": "xhigh", "ultrathink": null }, + "modelSuffixes": { "contextWindow": { "1m": "[1m]" } }, + "contextWindowTokens": { "200k": 200000, "1m": 1000000 } + } + } + }, + "opus-5": { + "capabilities": { + "optionDescriptors": [ + { + "id": "effort", + "label": "Reasoning", + "type": "select", + "options": [ + { "id": "low", "label": "Low" }, + { "id": "medium", "label": "Medium" }, + { "id": "high", "label": "High", "isDefault": true }, + { "id": "xhigh", "label": "Extra High" }, + { "id": "max", "label": "Max" }, + { + "id": "ultracode", + "label": "Ultracode", + "description": "xhigh effort plus multi-agent workflow orchestration" + }, + { "id": "ultrathink", "label": "Ultrathink" } + ], + "promptInjectedValues": ["ultrathink"] + }, + { "id": "fastMode", "label": "Fast Mode", "type": "boolean" }, + { + "id": "contextWindow", + "label": "Context Window", + "type": "select", + "options": [ + { "id": "200k", "label": "200k" }, + { "id": "1m", "label": "1M", "isDefault": true } + ] + } + ] + }, + "adapter": { + "claudeCode": { + "effortMap": { "ultracode": "xhigh", "ultrathink": null }, + "modelSuffixes": { "contextWindow": { "1m": "[1m]" } }, + "contextWindowTokens": { "200k": 200000, "1m": 1000000 } + } + } + }, + "opus-4-8": { + "capabilities": { + "optionDescriptors": [ + { + "id": "effort", + "label": "Reasoning", + "type": "select", + "options": [ + { "id": "low", "label": "Low" }, + { "id": "medium", "label": "Medium" }, + { "id": "high", "label": "High", "isDefault": true }, + { "id": "xhigh", "label": "Extra High" }, + { "id": "max", "label": "Max" }, + { + "id": "ultracode", + "label": "Ultracode", + "description": "xhigh effort plus multi-agent workflow orchestration" + }, + { "id": "ultrathink", "label": "Ultrathink" } + ], + "promptInjectedValues": ["ultrathink"] + }, + { "id": "fastMode", "label": "Fast Mode", "type": "boolean" } + ] + }, + "adapter": { + "claudeCode": { + "effortMap": { "ultracode": "xhigh", "ultrathink": null }, + "fixedContextWindowTokens": 1000000 + } + } + }, + "opus-4-7": { + "capabilities": { + "optionDescriptors": [ + { + "id": "effort", + "label": "Reasoning", + "type": "select", + "options": [ + { "id": "low", "label": "Low" }, + { "id": "medium", "label": "Medium" }, + { "id": "high", "label": "High" }, + { "id": "xhigh", "label": "Extra High", "isDefault": true }, + { "id": "max", "label": "Max" }, + { "id": "ultrathink", "label": "Ultrathink" } + ], + "promptInjectedValues": ["ultrathink"] + }, + { "id": "fastMode", "label": "Fast Mode", "type": "boolean" } + ] + }, + "adapter": { + "claudeCode": { + "effortMap": { "xhigh": "max", "ultrathink": null }, + "fixedContextWindowTokens": 1000000 + } + } + }, + "opus-4-6": { + "capabilities": { + "optionDescriptors": [ + { + "id": "effort", + "label": "Reasoning", + "type": "select", + "options": [ + { "id": "low", "label": "Low" }, + { "id": "medium", "label": "Medium" }, + { "id": "high", "label": "High", "isDefault": true }, + { "id": "max", "label": "Max" }, + { "id": "ultrathink", "label": "Ultrathink" } + ], + "promptInjectedValues": ["ultrathink"] + }, + { "id": "fastMode", "label": "Fast Mode", "type": "boolean" }, + { + "id": "contextWindow", + "label": "Context Window", + "type": "select", + "options": [ + { "id": "200k", "label": "200k" }, + { "id": "1m", "label": "1M", "isDefault": true } + ] + } + ] + }, + "adapter": { + "claudeCode": { + "effortMap": { "ultrathink": null }, + "modelSuffixes": { "contextWindow": { "1m": "[1m]" } }, + "contextWindowTokens": { "200k": 200000, "1m": 1000000 } + } + } + }, + "opus-4-5": { + "capabilities": { + "optionDescriptors": [ + { + "id": "effort", + "label": "Reasoning", + "type": "select", + "options": [ + { "id": "low", "label": "Low" }, + { "id": "medium", "label": "Medium" }, + { "id": "high", "label": "High", "isDefault": true }, + { "id": "max", "label": "Max" } + ] + }, + { "id": "fastMode", "label": "Fast Mode", "type": "boolean" } + ] + }, + "adapter": { "claudeCode": {} } + }, + "sonnet-5": { + "capabilities": { + "optionDescriptors": [ + { + "id": "effort", + "label": "Reasoning", + "type": "select", + "options": [ + { "id": "low", "label": "Low" }, + { "id": "medium", "label": "Medium" }, + { "id": "high", "label": "High", "isDefault": true }, + { "id": "xhigh", "label": "Extra High" }, + { "id": "max", "label": "Max" }, + { "id": "ultrathink", "label": "Ultrathink" } + ], + "promptInjectedValues": ["ultrathink"] + }, + { + "id": "contextWindow", + "label": "Context Window", + "type": "select", + "options": [ + { "id": "200k", "label": "200k", "isDefault": true }, + { "id": "1m", "label": "1M" } + ] + } + ] + }, + "adapter": { + "claudeCode": { + "effortMap": { "ultrathink": null }, + "modelSuffixes": { "contextWindow": { "1m": "[1m]" } }, + "contextWindowTokens": { "200k": 200000, "1m": 1000000 } + } + } + }, + "sonnet-4-6": { + "capabilities": { + "optionDescriptors": [ + { + "id": "effort", + "label": "Reasoning", + "type": "select", + "options": [ + { "id": "low", "label": "Low" }, + { "id": "medium", "label": "Medium" }, + { "id": "high", "label": "High", "isDefault": true }, + { "id": "max", "label": "Max" }, + { "id": "ultrathink", "label": "Ultrathink" } + ], + "promptInjectedValues": ["ultrathink"] + }, + { + "id": "contextWindow", + "label": "Context Window", + "type": "select", + "options": [ + { "id": "200k", "label": "200k", "isDefault": true }, + { "id": "1m", "label": "1M" } + ] + } + ] + }, + "adapter": { + "claudeCode": { + "effortMap": { "max": "high", "ultrathink": null }, + "modelSuffixes": { "contextWindow": { "1m": "[1m]" } }, + "contextWindowTokens": { "200k": 200000, "1m": 1000000 } + } + } + }, + "haiku-4-5": { + "capabilities": { + "optionDescriptors": [{ "id": "thinking", "label": "Thinking", "type": "boolean" }] + }, + "adapter": { "claudeCode": {} } + } + }, + "models": [ + { + "slug": "claude-fable-5-1", + "name": "Claude Fable 5.1", + "aliases": ["fable", "fable-5.1", "claude-fable-5.1"], + "status": "current", + "badge": "new", + "profile": "fable-5", + "adapter": { "claudeCode": { "minVersion": "2.1.257" } } + }, + { + "slug": "claude-fable-5", + "name": "Claude Fable 5", + "status": "legacy", + "profile": "fable-5", + "adapter": { "claudeCode": { "minVersion": "2.1.169" } } + }, + { + "slug": "claude-opus-5", + "name": "Claude Opus 5", + "aliases": ["opus", "opus-5", "claude-opus-5.0", "claude-opus-5-0"], + "status": "current", + "profile": "opus-5", + "adapter": { "claudeCode": { "minVersion": "2.1.219" } } + }, + { + "slug": "claude-opus-4-8", + "name": "Claude Opus 4.8", + "aliases": ["opus-4.8", "claude-opus-4.8"], + "status": "legacy", + "profile": "opus-4-8", + "adapter": { "claudeCode": { "minVersion": "2.1.154" } } + }, + { + "slug": "claude-opus-4-7", + "name": "Claude Opus 4.7", + "aliases": ["opus-4.7", "claude-opus-4.7"], + "status": "legacy", + "profile": "opus-4-7", + "adapter": { "claudeCode": { "minVersion": "2.1.111" } } + }, + { + "slug": "claude-opus-4-6", + "name": "Claude Opus 4.6", + "aliases": ["opus-4.6", "claude-opus-4.6", "claude-opus-4-6-20251117"], + "status": "legacy", + "profile": "opus-4-6" + }, + { + "slug": "claude-opus-4-5", + "name": "Claude Opus 4.5", + "status": "legacy", + "profile": "opus-4-5" + }, + { + "slug": "claude-sonnet-5", + "name": "Claude Sonnet 5", + "aliases": ["sonnet", "sonnet-5", "claude-sonnet-5.0", "claude-sonnet-5-0"], + "status": "current", + "profile": "sonnet-5" + }, + { + "slug": "claude-sonnet-4-6", + "name": "Claude Sonnet 4.6", + "aliases": ["sonnet-4.6", "claude-sonnet-4.6", "claude-sonnet-4-6-20251117"], + "status": "legacy", + "profile": "sonnet-4-6" + }, + { + "slug": "claude-haiku-4-5", + "name": "Claude Haiku 4.5", + "aliases": ["haiku", "haiku-4.5", "claude-haiku-4.5", "claude-haiku-4-5-20251001"], + "status": "legacy", + "profile": "haiku-4-5" + } + ] + } + } +} diff --git a/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts b/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts index f02bf997c5d1..35a7791c62f0 100644 --- a/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts +++ b/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts @@ -6,6 +6,7 @@ import { parseAgentListCliOutput, parseModelsCliOutput, parseSkillsCliOutput, + toOpenCodeFileParts, } from "./opencodeRuntime.ts"; describe("parseModelsCliOutput", () => { @@ -283,3 +284,38 @@ describe("parseSkillsCliOutput", () => { NodeAssert.deepEqual(parseSkillsCliOutput("not json"), []); }); }); + +describe("toOpenCodeFileParts", () => { + const attachment = (mimeType: string, sizeBytes = 12) => ({ + type: "file" as const, + id: "thread-1-00000000-0000-4000-8000-000000000001-bin", + name: "attachment", + mimeType, + sizeBytes, + }); + + it("sends supported images, text, and PDFs natively and skips what models reject", () => { + const parts = toOpenCodeFileParts({ + attachments: [ + attachment("application/pdf"), + attachment("text/markdown"), + attachment("image/png"), + // A ZIP file part makes OpenCode's Anthropic path throw before the + // turn starts; it must ride only as the prompt's file path line. + attachment("application/zip"), + attachment("application/octet-stream"), + // Image formats the model APIs reject stay on the fallback path too. + attachment("image/bmp"), + attachment("image/svg+xml"), + // Over the direct-attachment limit: path fallback even for a PDF. + attachment("application/pdf", 21 * 1024 * 1024), + ], + resolveAttachmentPath: () => "/tmp/attachment", + }); + + NodeAssert.deepEqual( + parts.map((part) => part.mime), + ["application/pdf", "text/markdown", "image/png"], + ); + }); +}); diff --git a/apps/server/src/provider/opencodeRuntime.environment.test.ts b/apps/server/src/provider/opencodeRuntime.environment.test.ts index b56921a686fb..584a9d80fb9c 100644 --- a/apps/server/src/provider/opencodeRuntime.environment.test.ts +++ b/apps/server/src/provider/opencodeRuntime.environment.test.ts @@ -1,6 +1,16 @@ +import type { OpencodeClient } from "@opencode-ai/sdk/v2"; +import { it as effectIt } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as TestClock from "effect/testing/TestClock"; import { describe, expect, it } from "vite-plus/test"; -import { resolveOpenCodeConfigContent } from "./opencodeRuntime.ts"; +import { + OpenCodeRuntimeError, + resolveOpenCodeConfigContent, + resolveOpenCodeServerPassword, + verifyOpenCodeServerVersion, +} from "./opencodeRuntime.ts"; describe("resolveOpenCodeConfigContent", () => { it("prefers the caller environment over the inherited environment", () => { @@ -21,3 +31,122 @@ describe("resolveOpenCodeConfigContent", () => { expect(resolveOpenCodeConfigContent(undefined, {})).toBe("{}"); }); }); + +describe("resolveOpenCodeServerPassword", () => { + it("uses the local environment password when settings do not provide one", () => { + expect( + resolveOpenCodeServerPassword( + { external: false, environment: { OPENCODE_SERVER_PASSWORD: " env password " } }, + {}, + ), + ).toBe(" env password "); + }); + + it("uses the settings password for a local server", () => { + expect( + resolveOpenCodeServerPassword({ external: false, serverPassword: " settings password " }, {}), + ).toBe(" settings password "); + }); + + it("uses the settings password when local settings and environment differ", () => { + expect( + resolveOpenCodeServerPassword( + { + external: false, + serverPassword: "settings-password", + environment: { OPENCODE_SERVER_PASSWORD: "environment-password" }, + }, + {}, + ), + ).toBe("settings-password"); + }); + + it("does not send an inherited local password to an external server", () => { + expect( + resolveOpenCodeServerPassword( + { external: true, environment: { OPENCODE_SERVER_PASSWORD: "local-secret" } }, + { OPENCODE_SERVER_PASSWORD: "inherited-secret" }, + ), + ).toBeUndefined(); + }); +}); + +function makeHealthClient( + result: (options?: { readonly signal?: AbortSignal }) => Promise, +): OpencodeClient { + return { + global: { + health: result, + }, + } as unknown as OpencodeClient; +} + +describe("verifyOpenCodeServerVersion", () => { + effectIt.effect("accepts a supported server version", () => + Effect.gen(function* () { + const version = yield* verifyOpenCodeServerVersion( + makeHealthClient(() => Promise.resolve({ data: { healthy: true, version: "1.14.19" } })), + ); + expect(version).toBe("1.14.19"); + }), + ); + + effectIt.effect("rejects a server below the supported version", () => + Effect.gen(function* () { + const error = yield* verifyOpenCodeServerVersion( + makeHealthClient(() => Promise.resolve({ data: { healthy: true, version: "1.14.18" } })), + ).pipe(Effect.flip); + expect(error).toBeInstanceOf(OpenCodeRuntimeError); + expect(error.detail).toContain("v1.14.18 is too old"); + }), + ); + + for (const data of [ + { healthy: true }, + { healthy: true, version: "not-a-version" }, + { healthy: false, version: "1.14.19" }, + ]) { + effectIt.effect(`rejects an invalid health response: ${JSON.stringify(data)}`, () => + Effect.gen(function* () { + const error = yield* verifyOpenCodeServerVersion( + makeHealthClient(() => Promise.resolve({ data })), + ).pipe(Effect.flip); + expect(error).toBeInstanceOf(OpenCodeRuntimeError); + expect(error.detail).toContain("requires OpenCode v1.14.19 or newer"); + }), + ); + } + + effectIt.effect("preserves an unauthorized health error", () => + Effect.gen(function* () { + const error = yield* verifyOpenCodeServerVersion( + makeHealthClient(() => + Promise.reject({ response: { status: 401 }, error: { message: "Unauthorized" } }), + ), + ).pipe(Effect.flip); + expect(error).toBeInstanceOf(OpenCodeRuntimeError); + expect(error.detail).toContain("status=401"); + expect(error.detail).toContain("Unauthorized"); + }), + ); + + effectIt.effect("aborts a health request when the version check times out", () => + Effect.gen(function* () { + let requestSignal: AbortSignal | undefined; + const checkFiber = yield* verifyOpenCodeServerVersion( + makeHealthClient((options) => { + requestSignal = options?.signal; + return new Promise(() => undefined); + }), + ).pipe(Effect.flip, Effect.forkChild); + + yield* Effect.yieldNow; + expect(requestSignal).toBeDefined(); + yield* TestClock.adjust("6 seconds"); + + const error = yield* Fiber.join(checkFiber); + expect(error.detail).toBe("Timed out while checking the OpenCode server version."); + expect(requestSignal?.aborted).toBe(true); + }).pipe(Effect.provide(TestClock.layer())), + ); +}); diff --git a/apps/server/src/provider/opencodeRuntime.inventory.test.ts b/apps/server/src/provider/opencodeRuntime.inventory.test.ts index 7db63745eafe..2a878a24ab8e 100644 --- a/apps/server/src/provider/opencodeRuntime.inventory.test.ts +++ b/apps/server/src/provider/opencodeRuntime.inventory.test.ts @@ -18,6 +18,34 @@ import { OpenCodeRuntime, OpenCodeRuntimeLive } from "./opencodeRuntime.ts"; const testLayer = OpenCodeRuntimeLive.pipe(Layer.provideMerge(NodeServices.layer)); it.layer(testLayer)("OpenCodeRuntime inventory", (it) => { + it.effect("keeps provider inventory when agent discovery fails", () => + Effect.gen(function* () { + const runtime = yield* OpenCodeRuntime; + const client = { + provider: { + list: () => + Promise.resolve({ + data: { + connected: ["openai"], + all: [], + default: {}, + }, + }), + }, + app: { + agents: () => Promise.reject(new Error("agents endpoint unavailable")), + skills: () => Promise.resolve({ data: [] }), + }, + } as unknown as OpencodeClient; + + const inventory = yield* runtime.loadOpenCodeInventory(client); + + NodeAssert.deepEqual(inventory.providerList.connected, ["openai"]); + NodeAssert.deepEqual(inventory.agents, []); + NodeAssert.deepEqual(inventory.skills, []); + }), + ); + it.effect("keeps provider inventory when skill discovery fails", () => Effect.gen(function* () { const runtime = yield* OpenCodeRuntime; diff --git a/apps/server/src/provider/opencodeRuntime.permissions.test.ts b/apps/server/src/provider/opencodeRuntime.permissions.test.ts index ad95e38d1495..be2696d7e100 100644 --- a/apps/server/src/provider/opencodeRuntime.permissions.test.ts +++ b/apps/server/src/provider/opencodeRuntime.permissions.test.ts @@ -39,6 +39,7 @@ describe("buildOpenCodePermissionRules", () => { it("allows everything only under full access", () => { NodeAssert.deepEqual(buildOpenCodePermissionRules("full-access"), [ { permission: "*", pattern: "*", action: "allow" }, + { permission: "external_directory", pattern: "*", action: "allow" }, ]); }); }); diff --git a/apps/server/src/provider/opencodeRuntime.ts b/apps/server/src/provider/opencodeRuntime.ts index 80329a6794d5..139628a287b1 100644 --- a/apps/server/src/provider/opencodeRuntime.ts +++ b/apps/server/src/provider/opencodeRuntime.ts @@ -33,10 +33,20 @@ import { isWindowsCommandNotFound } from "../processRunner.ts"; import { collectStreamAsString } from "./providerSnapshot.ts"; import * as NetService from "@t3tools/shared/Net"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { compareSemverVersions, parseSemver } from "@t3tools/shared/semver"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.fromJsonString(Schema.Unknown)); const OPENCODE_EMPTY_CONFIG_CONTENT = "{}"; +export const MINIMUM_OPENCODE_VERSION = "1.14.19"; +const OPENCODE_HEALTH_TIMEOUT = "5 seconds"; + +const OpenCodeHealthSchema = Schema.Struct({ + healthy: Schema.Literal(true), + version: Schema.String, +}); +const decodeOpenCodeHealth = Schema.decodeUnknownEffect(OpenCodeHealthSchema); + export function resolveOpenCodeConfigContent( inputEnvironment: Readonly> | undefined, inheritedEnvironment: Readonly> = process.env, @@ -48,17 +58,41 @@ export function resolveOpenCodeConfigContent( ); } +export function resolveOpenCodeServerPassword( + input: { + readonly external: boolean; + readonly serverPassword?: string; + readonly environment?: Readonly>; + }, + inheritedEnvironment: Readonly> = process.env, +): string | undefined { + if (input.serverPassword !== undefined) { + return input.serverPassword; + } + if (input.external) { + return undefined; + } + return input.environment === undefined + ? inheritedEnvironment.OPENCODE_SERVER_PASSWORD + : input.environment.OPENCODE_SERVER_PASSWORD; +} + const OPENCODE_SERVER_READY_PREFIX = "opencode server listening"; const DEFAULT_OPENCODE_SERVER_TIMEOUT_MS = 30_000; const DEFAULT_HOSTNAME = "127.0.0.1"; const OPENCODE_SKILL_DISCOVERY_MAX_OUTPUT_BYTES = 8 * 1024 * 1024; export interface OpenCodeServerProcess { readonly url: string; + readonly serverPassword?: string; + readonly version: string; + readonly isRunning: Effect.Effect; readonly exitCode: Effect.Effect; } export interface OpenCodeServerConnection { readonly url: string; + readonly serverPassword?: string; + readonly version: string; readonly exitCode: Effect.Effect | null; readonly external: boolean; } @@ -96,7 +130,7 @@ export function openCodeRuntimeErrorDetail(cause: unknown): string { export const runOpenCodeSdk = ( operation: string, - fn: () => Promise, + fn: (signal: AbortSignal) => Promise, ): Effect.Effect => Effect.tryPromise({ try: fn, @@ -104,6 +138,44 @@ export const runOpenCodeSdk = ( new OpenCodeRuntimeError({ operation, detail: openCodeRuntimeErrorDetail(cause), cause }), }).pipe(Effect.withSpan(`opencode.${operation}`)); +export const verifyOpenCodeServerVersion = Effect.fn("verifyOpenCodeServerVersion")(function* ( + client: OpencodeClient, +) { + const healthOption = yield* runOpenCodeSdk("global.health", (signal) => + client.global.health({ signal }), + ).pipe(Effect.timeoutOption(OPENCODE_HEALTH_TIMEOUT)); + if (Option.isNone(healthOption)) { + return yield* new OpenCodeRuntimeError({ + operation: "global.health", + detail: "Timed out while checking the OpenCode server version.", + }); + } + + const health = yield* decodeOpenCodeHealth(healthOption.value.data).pipe( + Effect.mapError( + (cause) => + new OpenCodeRuntimeError({ + operation: "global.health", + detail: `OpenCode server returned an invalid health response. T3 Code requires OpenCode v${MINIMUM_OPENCODE_VERSION} or newer.`, + cause, + }), + ), + ); + if (parseSemver(health.version) === null) { + return yield* new OpenCodeRuntimeError({ + operation: "global.health", + detail: `OpenCode server returned an invalid version. T3 Code requires OpenCode v${MINIMUM_OPENCODE_VERSION} or newer.`, + }); + } + if (compareSemverVersions(health.version, MINIMUM_OPENCODE_VERSION) < 0) { + return yield* new OpenCodeRuntimeError({ + operation: "global.health", + detail: `OpenCode v${health.version} is too old. Upgrade to v${MINIMUM_OPENCODE_VERSION} or newer.`, + }); + } + return health.version; +}); + export interface OpenCodeCommandResult { readonly stdout: string; readonly stderr: string; @@ -145,6 +217,8 @@ export interface OpenCodeRuntimeShape { */ readonly startOpenCodeServerProcess: (input: { readonly binaryPath: string; + readonly directory: string; + readonly serverPassword?: string; readonly environment?: NodeJS.ProcessEnv; readonly port?: number; readonly hostname?: string; @@ -157,7 +231,9 @@ export interface OpenCodeRuntimeShape { */ readonly connectToOpenCodeServer: (input: { readonly binaryPath: string; + readonly directory: string; readonly serverUrl?: string | null; + readonly serverPassword?: string; readonly environment?: NodeJS.ProcessEnv; readonly port?: number; readonly hostname?: string; @@ -345,6 +421,31 @@ export function openCodeQuestionId( return header.length > 0 ? `question-${index}-${header}` : `question-${index}`; } +/** + * Attachments OpenCode can hand to a model as a native file part. Anything + * else (ZIP, binaries, image formats like BMP/AVIF/SVG that model APIs + * reject, or files over the direct-attachment size limit) would make the turn + * fail before it starts, so those ride only as the file path ProviderService + * puts in the prompt. + */ +const OPENCODE_NATIVE_IMAGE_MIMES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]); +export const OPENCODE_NATIVE_FILE_PART_MAX_BYTES = 20 * 1024 * 1024; + +export function isOpenCodeNativeFilePart(input: { + readonly mimeType: string; + readonly sizeBytes: number; +}): boolean { + if (input.sizeBytes > OPENCODE_NATIVE_FILE_PART_MAX_BYTES) { + return false; + } + const normalized = input.mimeType.trim().toLowerCase(); + return ( + OPENCODE_NATIVE_IMAGE_MIMES.has(normalized) || + normalized.startsWith("text/") || + normalized === "application/pdf" + ); +} + export function toOpenCodeFileParts(input: { readonly attachments: ReadonlyArray | undefined; readonly resolveAttachmentPath: (attachment: ChatAttachment) => string | null; @@ -352,6 +453,9 @@ export function toOpenCodeFileParts(input: { const parts: Array = []; for (const attachment of input.attachments ?? []) { + if (!isOpenCodeNativeFilePart(attachment)) { + continue; + } const attachmentPath = input.resolveAttachmentPath(attachment); if (!attachmentPath) { continue; @@ -370,7 +474,10 @@ export function toOpenCodeFileParts(input: { export function buildOpenCodePermissionRules(runtimeMode: RuntimeMode): PermissionRuleset { if (runtimeMode === "full-access") { - return [{ permission: "*", pattern: "*", action: "allow" }]; + return [ + { permission: "*", pattern: "*", action: "allow" }, + { permission: "external_directory", pattern: "*", action: "allow" }, + ]; } // "Auto-accept edits" is documented as "auto-approve edits, ask before other @@ -486,6 +593,20 @@ const makeOpenCodeRuntime = Effect.gen(function* () { ), ); + const createOpenCodeSdkClient: OpenCodeRuntimeShape["createOpenCodeSdkClient"] = (input) => + createOpencodeClient({ + baseUrl: input.baseUrl, + directory: input.directory, + ...(input.serverPassword + ? { + headers: { + Authorization: `Basic ${Buffer.from(`opencode:${input.serverPassword}`, "utf8").toString("base64")}`, + }, + } + : {}), + throwOnError: true, + }); + const startOpenCodeServerProcess: OpenCodeRuntimeShape["startOpenCodeServerProcess"] = (input) => Effect.gen(function* () { // Bind this server's lifetime to the caller's scope. When the caller's @@ -509,6 +630,11 @@ const makeOpenCodeRuntime = Effect.gen(function* () { const timeoutMs = input.timeoutMs ?? DEFAULT_OPENCODE_SERVER_TIMEOUT_MS; const args = ["serve", `--hostname=${hostname}`, `--port=${port}`]; const spawnCommand = yield* resolveCommand(input.binaryPath, args, input.environment); + const serverPassword = resolveOpenCodeServerPassword({ + external: false, + ...(input.serverPassword !== undefined ? { serverPassword: input.serverPassword } : {}), + ...(input.environment !== undefined ? { environment: input.environment } : {}), + }); const child = yield* spawner .spawn( @@ -517,6 +643,7 @@ const makeOpenCodeRuntime = Effect.gen(function* () { shell: spawnCommand.shell, env: { ...input.environment, + ...(serverPassword !== undefined ? { OPENCODE_SERVER_PASSWORD: serverPassword } : {}), // Respect an OPENCODE_CONFIG_CONTENT provided by the caller or // the inherited process environment, only falling back to the // empty config when neither is set. Setting it unconditionally @@ -642,8 +769,20 @@ const makeOpenCodeRuntime = Effect.gen(function* () { }); } + const url = readyOption.value; + const version = yield* verifyOpenCodeServerVersion( + createOpenCodeSdkClient({ + baseUrl: url, + directory: input.directory, + ...(serverPassword !== undefined ? { serverPassword } : {}), + }), + ); + return { - url: readyOption.value, + url, + ...(serverPassword !== undefined ? { serverPassword } : {}), + version, + isRunning: child.isRunning.pipe(Effect.orElseSucceed(() => false)), exitCode: child.exitCode.pipe( Effect.map(Number), Effect.orElseSucceed(() => 0), @@ -654,16 +793,31 @@ const makeOpenCodeRuntime = Effect.gen(function* () { const connectToOpenCodeServer: OpenCodeRuntimeShape["connectToOpenCodeServer"] = (input) => { const serverUrl = input.serverUrl?.trim(); if (serverUrl) { - // We don't own externally-configured servers — no scope interaction. - return Effect.succeed({ - url: serverUrl, - exitCode: null, + const serverPassword = resolveOpenCodeServerPassword({ external: true, + ...(input.serverPassword !== undefined ? { serverPassword: input.serverPassword } : {}), }); + return verifyOpenCodeServerVersion( + createOpenCodeSdkClient({ + baseUrl: serverUrl, + directory: input.directory, + ...(serverPassword !== undefined ? { serverPassword } : {}), + }), + ).pipe( + Effect.map((version) => ({ + url: serverUrl, + ...(serverPassword !== undefined ? { serverPassword } : {}), + version, + exitCode: null, + external: true, + })), + ); } return startOpenCodeServerProcess({ binaryPath: input.binaryPath, + directory: input.directory, + ...(input.serverPassword !== undefined ? { serverPassword: input.serverPassword } : {}), ...(input.environment !== undefined ? { environment: input.environment } : {}), ...(input.port !== undefined ? { port: input.port } : {}), ...(input.hostname !== undefined ? { hostname: input.hostname } : {}), @@ -671,26 +825,14 @@ const makeOpenCodeRuntime = Effect.gen(function* () { }).pipe( Effect.map((server) => ({ url: server.url, + ...(server.serverPassword !== undefined ? { serverPassword: server.serverPassword } : {}), + version: server.version, exitCode: server.exitCode, external: false, })), ); }; - const createOpenCodeSdkClient: OpenCodeRuntimeShape["createOpenCodeSdkClient"] = (input) => - createOpencodeClient({ - baseUrl: input.baseUrl, - directory: input.directory, - ...(input.serverPassword - ? { - headers: { - Authorization: `Basic ${Buffer.from(`opencode:${input.serverPassword}`, "utf8").toString("base64")}`, - }, - } - : {}), - throwOnError: true, - }); - const loadProviders = (client: OpencodeClient) => runOpenCodeSdk("provider.list", () => client.provider.list()).pipe( Effect.filterMapOrFail( @@ -710,6 +852,7 @@ const makeOpenCodeRuntime = Effect.gen(function* () { const loadAgents = (client: OpencodeClient) => runOpenCodeSdk("app.agents", () => client.app.agents()).pipe( Effect.map((result) => result.data ?? []), + Effect.orElseSucceed((): ReadonlyArray => []), ); const loadSkills = (client: OpencodeClient) => diff --git a/apps/server/src/provider/providerStatusCache.ts b/apps/server/src/provider/providerStatusCache.ts index 2fe0424b4f57..abc625f17316 100644 --- a/apps/server/src/provider/providerStatusCache.ts +++ b/apps/server/src/provider/providerStatusCache.ts @@ -1,5 +1,4 @@ import { - type ProviderDriverKind, type ProviderInstanceId, type ServerProvider, ServerProvider as ServerProviderSchema, @@ -98,23 +97,6 @@ export const resolveProviderStatusCachePath = Effect.fn("resolveProviderStatusCa }, ); -/** - * Legacy kind-keyed path resolver retained for callers that still think in - * terms of `ProviderDriverKind`. Prefer `resolveProviderStatusCachePath` with an - * `instanceId`; new code should route through the instance registry. - * - * @deprecated use `resolveProviderStatusCachePath` with an instance id. - */ -export const resolveLegacyProviderStatusCachePath = Effect.fn( - "resolveLegacyProviderStatusCachePath", -)(function* (input: { - readonly cacheDir: string; - readonly provider: ProviderDriverKind; -}): Effect.fn.Return { - const path = yield* Path.Path; - return path.join(input.cacheDir, `${input.provider}.json`); -}); - export const readProviderStatusCache = (filePath: string) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs b/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs index d3bdee367125..4e6d1b261947 100644 --- a/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs +++ b/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs @@ -57,7 +57,55 @@ rl.on("line", (line) => { }); return; } - if (method === "thread/start" || method === "thread/resume") { + if (method === "thread/start") { + write({ id, result: fixture.responses.threadStart }); + return; + } + if (method === "thread/resume") { + if (script.recordRequests) { + NodeFS.appendFileSync( + `${process.env.T3_CODEX_COLLAB_SCRIPT}.requests`, + `${JSON.stringify({ method, params: message.params })}\n`, + ); + } + const threadId = message.params?.threadId; + const childSnapshot = script.childResumeSnapshots?.[threadId]; + if (script.resumeRequestMarker) { + write({ + jsonrpc: "2.0", + method: "serverRequest/resolved", + params: { + threadId: script.rootThreadId, + requestId: script.resumeRequestMarker, + }, + }); + } + if (childSnapshot?.hang) { + return; + } + if (childSnapshot?.error) { + write({ id, error: { code: -32000, message: childSnapshot.error } }); + return; + } + if (childSnapshot) { + write({ + id, + result: { + ...fixture.responses.threadStart, + model: childSnapshot.model, + reasoningEffort: childSnapshot.reasoningEffort, + thread: { + ...fixture.responses.threadStart.thread, + id: threadId, + sessionId: threadId, + }, + }, + }); + for (const notification of childSnapshot.notifications ?? []) { + write({ jsonrpc: "2.0", method: notification.method, params: notification.params }); + } + return; + } write({ id, result: fixture.responses.threadStart }); return; } diff --git a/apps/server/src/resourceTelemetry/NativeTelemetryClient.test.ts b/apps/server/src/resourceTelemetry/NativeTelemetryClient.test.ts index 61a67d116069..8a595bc8b480 100644 --- a/apps/server/src/resourceTelemetry/NativeTelemetryClient.test.ts +++ b/apps/server/src/resourceTelemetry/NativeTelemetryClient.test.ts @@ -44,7 +44,7 @@ describe("resolveNativeSampleIntervalMs", () => { expect(resolveNativeSampleIntervalMs({ ...basePower, onBattery: "true" }, 1)).toBe(5_000); }); - it("keeps unknown background telemetry cheap but serves live diagnostics at 1Hz", () => { + it("slows background telemetry and serves live diagnostics at 1Hz", () => { const unknown: HostPowerSnapshot = { ...basePower, source: "unknown", @@ -58,7 +58,8 @@ describe("resolveNativeSampleIntervalMs", () => { 0, ), ).toBe(5_000); - expect(resolveNativeSampleIntervalMs(basePower, 0)).toBe(1_000); + expect(resolveNativeSampleIntervalMs(basePower, 0)).toBe(5_000); + expect(resolveNativeSampleIntervalMs(basePower, 1)).toBe(1_000); }); }); diff --git a/apps/server/src/resourceTelemetry/NativeTelemetryClient.ts b/apps/server/src/resourceTelemetry/NativeTelemetryClient.ts index e8d81cc4c1c0..232079d9dc9b 100644 --- a/apps/server/src/resourceTelemetry/NativeTelemetryClient.ts +++ b/apps/server/src/resourceTelemetry/NativeTelemetryClient.ts @@ -268,7 +268,7 @@ export function resolveNativeSampleIntervalMs( return CONSTRAINED_SAMPLE_INTERVAL_MS; } if (snapshot.onBattery === "true") return BATTERY_SAMPLE_INTERVAL_MS; - return SAMPLE_INTERVAL_MS; + return liveSubscriberCount > 0 ? SAMPLE_INTERVAL_MS : UNKNOWN_BACKGROUND_SAMPLE_INTERVAL_MS; } export function commitCollectionControlUpdate( @@ -462,13 +462,16 @@ export const make = Effect.fn("resourceTelemetry.nativeTelemetryClient.make")(fu return Effect.gen(function* () { const nativeSnapshot = { generation, snapshot: event } satisfies NativeTelemetrySnapshot; const sampledAt = DateTime.makeUnsafe(event.sampledAtUnixMs); - yield* Ref.update(state, (current) => ({ - ...current, - status: "healthy" as const, - lastSampleAt: Option.some(sampledAt), - lastError: Option.none(), - })); - yield* publishHealth; + const healthChanged = yield* Ref.modify(state, (current) => [ + current.status !== "healthy" || Option.isSome(current.lastError), + { + ...current, + status: "healthy" as const, + lastSampleAt: Option.some(sampledAt), + lastError: Option.none(), + }, + ]); + if (healthChanged) yield* publishHealth; yield* PubSub.publish(snapshots, nativeSnapshot); if (event.requestId) { const deferred = yield* Ref.modify(pendingSamples, (pending) => { @@ -485,15 +488,18 @@ export const make = Effect.fn("resourceTelemetry.nativeTelemetryClient.make")(fu case "historyChunk": return Effect.gen(function* () { const latestSnapshot = event.snapshots.at(-1); - yield* Ref.update(state, (current) => ({ - ...current, - status: "healthy" as const, - lastSampleAt: latestSnapshot - ? Option.some(DateTime.makeUnsafe(latestSnapshot.sampledAtUnixMs)) - : current.lastSampleAt, - lastError: Option.none(), - })); - yield* publishHealth; + const healthChanged = yield* Ref.modify(state, (current) => [ + current.status !== "healthy" || Option.isSome(current.lastError), + { + ...current, + status: "healthy" as const, + lastSampleAt: latestSnapshot + ? Option.some(DateTime.makeUnsafe(latestSnapshot.sampledAtUnixMs)) + : current.lastSampleAt, + lastError: Option.none(), + }, + ]); + if (healthChanged) yield* publishHealth; const completed = yield* Ref.modify(pendingHistories, (pending) => { const request = pending.get(event.requestId); if (!request) return [Option.none(), pending] as const; diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 5e4f19172eff..8170901a21ff 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -10,6 +10,7 @@ import { AuthTokenExchangeGrantType, CommandId, DEFAULT_SERVER_SETTINGS, + type DpopFailureReason, EnvironmentId, EventId, GitCommandError, @@ -18,6 +19,7 @@ import { ExternalLauncherCommandNotFoundError, OrchestrationThreadDetailSnapshot, type OrchestrationThreadStreamItem, + type OrchestrationThreadActivity, type OrchestrationThreadShell, TerminalNotRunningError, type OrchestrationCommand, @@ -29,6 +31,7 @@ import { ProviderInstanceId, ResolvedKeybindingRule, ThreadId, + TurnId, WS_METHODS, WsRpcGroup, EditorId, @@ -56,6 +59,7 @@ import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as PubSub from "effect/PubSub"; import * as Queue from "effect/Queue"; +import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; @@ -101,16 +105,25 @@ const collectQueueUntil = Effect.fn("TransferBudget.collectQueueUntil")(function import * as BackgroundPolicy from "./background/BackgroundPolicy.ts"; import * as ServerConfig from "./config.ts"; -import { makeRoutesLayer } from "./server.ts"; -import { isThreadDetailEvent, resolveAvailableEditorsForConfig } from "./ws.ts"; +import { HTTP_ROUTER_CONFIG, makeRoutesLayer } from "./server.ts"; +import { + isThreadDetailEvent, + resolveAvailableEditorsForConfig, + resolveFileManagerRevealKindForConfig, +} from "./ws.ts"; import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; import * as GitManager from "./git/GitManager.ts"; +import * as EnvironmentTheme from "./environmentTheme.ts"; import * as Keybindings from "./keybindings.ts"; import * as ExternalLauncher from "./process/externalLauncher.ts"; import * as RemoteOpenTargets from "./environment/RemoteOpenTargets.ts"; import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts"; -import { OrchestrationListenerCallbackError } from "./orchestration/Errors.ts"; +import { + OrchestrationListenerCallbackError, + OrchestrationThreadSettleBlockedError, +} from "./orchestration/Errors.ts"; import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts"; +import { ThreadDeletionReactor } from "./orchestration/Services/ThreadDeletionReactor.ts"; import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite.ts"; import { PersistenceSqlError } from "./persistence/Errors.ts"; import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts"; @@ -189,6 +202,44 @@ const defaultModelSelection = { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", } as const; + +const makeLiveToolActivityEvent = ( + sequence: number, + kind: "tool.updated" | "tool.completed" = "tool.updated", + options: { + readonly toolCallId?: string; + readonly title?: string; + readonly path?: string; + } = {}, +): Extract => { + const { toolCallId = "call-edit", title = "Editing app.ts", path = "src/app.ts" } = options; + const activity: OrchestrationThreadActivity = { + id: EventId.make(`activity-${sequence}`), + tone: "tool", + kind, + summary: title, + payload: { + itemType: "file_change", + title, + data: { toolCallId, path }, + }, + turnId: TurnId.make("turn-edit"), + createdAt: "2026-01-01T00:00:01.000Z", + }; + return { + sequence, + eventId: EventId.make(`event-tool-${sequence}`), + aggregateKind: "thread", + aggregateId: defaultThreadId, + occurredAt: "2026-01-01T00:00:01.000Z", + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + type: "thread.activity-appended", + payload: { threadId: defaultThreadId, activity }, + }; +}; const testEnvironmentDescriptor = { environmentId: EnvironmentId.make("environment-test"), label: "Test environment", @@ -283,6 +334,11 @@ const makeAuthTestLayer = () => EnvironmentAuth.layer.pipe( Layer.provide(SqlitePersistenceMemory), Layer.provide(ServerSecretStore.layer), + Layer.provide( + Layer.mock(ServerEnvironment.ServerEnvironmentIdentity)({ + getEnvironmentId: Effect.succeed(testEnvironmentDescriptor.environmentId), + }), + ), ); const makeBrowserOtlpPayload = (spanName: string) => @@ -388,6 +444,7 @@ const buildAppUnderTest = (options?: { config?: Partial; layers?: { keybindings?: Partial; + environmentTheme?: Partial; providerRegistry?: Partial; providerService?: Partial; serverSettings?: Partial; @@ -406,6 +463,7 @@ const buildAppUnderTest = (options?: { >; terminalManager?: Partial; orchestrationEngine?: Partial; + threadDeletionReactor?: Partial; analyticsService?: Partial; projectionSnapshotQuery?: Partial; checkpointDiffQuery?: Partial; @@ -619,17 +677,25 @@ const buildAppUnderTest = (options?: { { disableListenLog: true, disableLogger: true, + routerConfig: HTTP_ROUTER_CONFIG, }, ).pipe( Layer.provide( - Layer.mock(Keybindings.Keybindings)({ - loadConfigState: Effect.succeed({ - keybindings: [], - issues: [], + Layer.mergeAll( + Layer.mock(Keybindings.Keybindings)({ + loadConfigState: Effect.succeed({ + keybindings: [], + issues: [], + }), + streamChanges: Stream.empty, + ...options?.layers?.keybindings, }), - streamChanges: Stream.empty, - ...options?.layers?.keybindings, - }), + Layer.mock(EnvironmentTheme.EnvironmentThemeService)({ + current: Effect.succeed([]), + streamChanges: Stream.empty, + ...options?.layers?.environmentTheme, + }), + ), ), Layer.provide( Layer.mergeAll( @@ -665,6 +731,7 @@ const buildAppUnderTest = (options?: { Layer.mergeAll( Layer.mock(ExternalLauncher.ExternalLauncher)({ resolveAvailableEditors: () => Effect.succeed([]), + resolveFileManagerRevealKind: () => Effect.sync((): undefined => undefined), ...options?.layers?.externalLauncher, }), Layer.mock(RemoteOpenTargets.RemoteOpenTargets)({ @@ -781,13 +848,20 @@ const buildAppUnderTest = (options?: { ), ), Layer.provide( - Layer.mock(OrchestrationEngine.OrchestrationEngineService)({ - readEvents: () => Stream.empty, - dispatch: () => Effect.succeed({ sequence: 0 }), - streamDomainEvents: Stream.empty, - latestSequence: Effect.succeed(0), - ...options?.layers?.orchestrationEngine, - }), + Layer.mergeAll( + Layer.mock(OrchestrationEngine.OrchestrationEngineService)({ + readEvents: () => Stream.empty, + dispatch: () => Effect.succeed({ sequence: 0 }), + streamDomainEvents: Stream.empty, + latestSequence: Effect.succeed(0), + ...options?.layers?.orchestrationEngine, + }), + Layer.mock(ThreadDeletionReactor)({ + start: () => Effect.void, + drainThrough: () => Effect.void, + ...options?.layers?.threadDeletionReactor, + }), + ), ), Layer.provide( Layer.mock(ProjectionSnapshotQuery.ProjectionSnapshotQuery)({ @@ -1110,6 +1184,7 @@ const exchangeAccessToken = ( readonly _tag?: string; readonly code?: string; readonly reason?: string; + readonly dpopFailureReason?: DpopFailureReason; readonly traceId?: string; }>(response); return { @@ -1501,6 +1576,41 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("serves snapshots for MCP handoff thread IDs above the router default", () => + Effect.gen(function* () { + const threadId = ThreadId.make( + "thread:mcp:abfba0d2-b591-4b7e-aad1-e943d89811fa:handoff%3A0ae5edf4-2ea3-4ee3-ba7c-48de3ac92896%3A2026-08-24T17%3A08%3A52.138Z:0", + ); + const thread = { + ...makeDefaultOrchestrationReadModel().threads[0]!, + id: threadId, + }; + yield* buildAppUnderTest({ + layers: { + projectionSnapshotQuery: { + getThreadDetailSnapshot: (requestedThreadId) => + Effect.succeed( + requestedThreadId === threadId + ? Option.some({ snapshotSequence: 1, thread }) + : Option.none(), + ), + }, + }, + }); + + const response = yield* fetchEffect( + yield* getHttpServerUrl(`/api/orchestration/threads/${encodeURIComponent(threadId)}`), + { headers: { cookie: yield* getAuthenticatedSessionCookieHeader() } }, + ); + const snapshot = yield* responseJsonEffect<{ + readonly thread: { readonly id: ThreadId }; + }>(response); + + assert.equal(response.status, 200); + assert.equal(snapshot.thread.id, threadId); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("compresses large JSON responses through the composed routes", () => Effect.gen(function* () { const descriptor = { @@ -1612,6 +1722,48 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("migrates a valid legacy remote-web session cookie", () => + Effect.gen(function* () { + yield* buildAppUnderTest({ config: { mode: "web", host: "192.168.1.50" } }); + + const { cookie } = yield* bootstrapBrowserSession(); + const currentCookie = cookie?.split(";")[0] ?? ""; + const legacyCookie = currentCookie.replace(/^t3_session_[^=]+=/, "t3_session="); + const sessionUrl = yield* getHttpServerUrl("/api/auth/session"); + const response = yield* fetchEffect(sessionUrl, { + headers: { cookie: legacyCookie }, + }); + const body = yield* responseJsonEffect<{ readonly authenticated: boolean }>(response); + + assert.equal(body.authenticated, true); + assert.equal(response.headers["set-cookie"], cookie); + assert.equal(response.headers["cache-control"], "no-store"); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect.each(["cookie", "bearer"])( + "does not migrate a stale legacy cookie when %s auth succeeds", + (source) => + Effect.gen(function* () { + yield* buildAppUnderTest({ config: { mode: "web", host: "192.168.1.50" } }); + + const { cookie } = yield* bootstrapBrowserSession(); + const sessionCookie = cookie?.split(";")[0] ?? ""; + const sessionToken = extractSessionTokenFromSetCookie(cookie ?? ""); + const sessionUrl = yield* getHttpServerUrl("/api/auth/session"); + const response = yield* fetchEffect(sessionUrl, { + headers: + source === "cookie" + ? { cookie: `${sessionCookie}; t3_session=stale` } + : { authorization: `Bearer ${sessionToken}`, cookie: "t3_session=stale" }, + }); + const body = yield* responseJsonEffect<{ readonly authenticated: boolean }>(response); + + assert.equal(body.authenticated, true); + assert.isUndefined(response.headers["set-cookie"]); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("exchanges a bootstrap grant for a scoped bearer access token", () => Effect.gen(function* () { yield* buildAppUnderTest(); @@ -1792,6 +1944,38 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("reports clock skew for a future-dated DPoP token exchange proof", () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + + const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); + const credentialResponse = yield* HttpClient.post("/api/auth/pairing-token", { + headers: { cookie: ownerCookie }, + body: yield* HttpBody.json({}), + }); + const credential = (yield* credentialResponse.json) as { readonly credential: string }; + const tokenUrl = yield* getHttpServerUrl("/oauth/token"); + const now = yield* DateTime.now; + const dpop = makeDpopProof({ + method: "POST", + url: tokenUrl, + iat: Math.floor(now.epochMilliseconds / 1_000) + 25, + }); + + const exchange = yield* exchangeAccessToken(credential.credential, { + headers: { dpop: dpop.proof }, + scope: "orchestration:read orchestration:operate terminal:operate review:write", + }); + + assert.equal(exchange.response.status, 401); + assert.equal(exchange.body._tag, "EnvironmentAuthInvalidError"); + assert.equal(exchange.body.code, "auth_invalid"); + assert.equal(exchange.body.reason, "invalid_credential"); + assert.equal(exchange.body.dpopFailureReason, "time_window"); + assert.equal(typeof exchange.body.traceId, "string"); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("rejects replayed DPoP proofs across token exchanges", () => Effect.gen(function* () { yield* buildAppUnderTest(); @@ -1841,6 +2025,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { assert.equal(replayBootstrap.body._tag, "EnvironmentAuthInvalidError"); assert.equal(replayBootstrap.body.code, "auth_invalid"); assert.equal(replayBootstrap.body.reason, "invalid_credential"); + assert.equal(replayBootstrap.body.dpopFailureReason, "replay"); assert.equal(typeof replayBootstrap.body.traceId, "string"); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); @@ -1916,6 +2101,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { assert.equal(bootstrap.body._tag, "EnvironmentAuthInvalidError"); assert.equal(bootstrap.body.code, "auth_invalid"); assert.equal(bootstrap.body.reason, "invalid_credential"); + assert.equal(bootstrap.body.dpopFailureReason, "request_mismatch"); assert.equal(typeof bootstrap.body.traceId, "string"); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); @@ -4031,10 +4217,38 @@ it.layer(NodeServices.layer)("server router seam", (it) => { assert.equal(response.environment.environmentId, testEnvironmentDescriptor.environmentId); assert.equal(response.auth.policy, "desktop-managed-local"); assert.equal(response.shellResumeCompletionMarker, true); + assert.isUndefined(response.shellRevealInFileManager); + assert.isUndefined(response.shellRevealInFileManagerKind); assert.equal(response.threadResumeCompletionMarker, true); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("advertises the usable file manager and its reveal label", () => + Effect.gen(function* () { + yield* buildAppUnderTest({ + layers: { + externalLauncher: { + resolveAvailableEditors: () => Effect.succeed(["file-manager"]), + resolveFileManagerRevealKind: () => Effect.succeed("file-explorer"), + }, + }, + }); + + const { cookie } = yield* bootstrapBrowserSession(); + const wsUrl = appendSessionCookieToWsUrl( + yield* getWsServerUrl("/ws", { authenticated: false }), + cookie?.split(";")[0] ?? "", + ); + const response = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => client[WS_METHODS.serverGetConfig]({})), + ); + + assert.deepEqual(response.availableEditors, ["file-manager"]); + assert.equal(response.shellRevealInFileManager, true); + assert.equal(response.shellRevealInFileManagerKind, "file-explorer"); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("does not block server config when editor discovery never resolves", () => Effect.gen(function* () { const discoveryInterrupted = yield* Deferred.make(); @@ -4052,6 +4266,23 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }), ); + it.effect("does not block server config when file manager reveal discovery never resolves", () => + Effect.gen(function* () { + const discoveryInterrupted = yield* Deferred.make(); + const responseFiber = yield* resolveFileManagerRevealKindForConfig( + Effect.never.pipe( + Effect.onInterrupt(() => Deferred.succeed(discoveryInterrupted, undefined)), + ), + ).pipe(Effect.forkChild); + + yield* TestClock.adjust(Duration.seconds(5)); + + const revealKind = yield* Fiber.join(responseFiber); + yield* Deferred.await(discoveryInterrupted); + assert.isUndefined(revealKind); + }), + ); + it.effect( "rejects websocket rpc handshake when a session token is only provided via query string", () => @@ -4529,6 +4760,113 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }); assert.equal(streamedResponse.status, 204); yield* client[WS_METHODS.attachmentsDelete]({ attachmentId: streamed.attachmentId }); + + const uploadedFile = yield* client[WS_METHODS.attachmentsCreateUploadUrl]({ + type: "file", + name: "report.pdf", + mimeType: "application/pdf", + sizeBytes: 6, + }); + const fileResponse = yield* HttpClient.post(uploadedFile.relativeUrl, { + body: HttpBody.stream( + Stream.make(new Uint8Array([1, 2, 3]), new Uint8Array([4, 5, 6])), + "application/pdf", + ), + }); + assert.equal(fileResponse.status, 204); + const uploadedFilePath = path.join( + config.attachmentsDir, + `${uploadedFile.attachmentId}.pdf`, + ); + assert.isTrue(yield* fileSystem.exists(uploadedFilePath)); + + // A mint that carries the attachment's display name and mime + // serves a real download filename and Content-Type. + const download = yield* client[WS_METHODS.assetsCreateUrl]({ + resource: { + _tag: "attachment", + attachmentId: uploadedFile.attachmentId, + fileName: "report.pdf", + mimeType: "application/pdf", + }, + }); + const downloadResponse = yield* HttpClient.get(download.relativeUrl); + assert.equal(downloadResponse.status, 200); + assert.equal( + downloadResponse.headers["content-disposition"], + 'attachment; filename="report.pdf"', + ); + assert.equal(downloadResponse.headers["content-type"], "application/pdf"); + + // Old clients mint without name or mime and still get a download. + const bareDownload = yield* client[WS_METHODS.assetsCreateUrl]({ + resource: { _tag: "attachment", attachmentId: uploadedFile.attachmentId }, + }); + const bareResponse = yield* HttpClient.get(bareDownload.relativeUrl); + assert.equal(bareResponse.status, 200); + assert.equal(bareResponse.headers["content-disposition"], "attachment"); + assert.equal(bareResponse.headers["content-type"], "application/octet-stream"); + + yield* client[WS_METHODS.attachmentsDelete]({ + attachmentId: uploadedFile.attachmentId, + }); + assert.isFalse(yield* fileSystem.exists(uploadedFilePath)); + }), + ), + ); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("rejects an over-limit chunked upload through the route without hanging", () => + Effect.gen(function* () { + const config = yield* buildAppUnderTest(); + const fileSystem = yield* FileSystem.FileSystem; + const wsUrl = yield* getWsServerUrl("/ws"); + + yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + Effect.gen(function* () { + const issued = yield* client[WS_METHODS.attachmentsCreateUploadUrl]({ + type: "file", + name: "big.bin", + mimeType: "application/octet-stream", + sizeBytes: 6, + }); + const NodeHttp = yield* Effect.promise(() => import("node:http")); + const uploadUrl = new URL(issued.relativeUrl, yield* getHttpServerUrl()); + const status = yield* Effect.callback((resume) => { + let completed = false; + const complete = (result: Effect.Effect) => { + if (completed) return; + completed = true; + resume(result); + }; + const request = NodeHttp.request( + uploadUrl, + { + method: "POST", + headers: { + "content-type": "application/octet-stream", + "transfer-encoding": "chunked", + }, + }, + (response) => { + request.end(); + response.resume(); + response.once("end", () => complete(Effect.succeed(response.statusCode ?? 0))); + response.once("error", (error) => complete(Effect.fail(error))); + }, + ); + request.once("error", (error) => complete(Effect.fail(error))); + request.flushHeaders(); + request.write(new Uint8Array(4), () => { + request.write(new Uint8Array(4)); + }); + + return Effect.sync(() => request.destroy()); + }); + assert.equal(status, 400); + assert.deepEqual(yield* fileSystem.readDirectory(config.attachmentsDir), []); }), ), ); @@ -4646,6 +4984,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { it.effect("routes websocket rpc subscribeServerConfig streams snapshot then update", () => Effect.gen(function* () { + const path = yield* Path.Path; const providers = [ { instanceId: ProviderInstanceId.make("codex"), @@ -4699,7 +5038,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { assert.deepEqual(first.config.keybindings, []); assert.deepEqual(first.config.issues, []); assert.deepEqual(first.config.providers, providers); - assert.equal(first.config.observability.logsDirectoryPath.endsWith("/logs"), true); + assert.equal(path.basename(first.config.observability.logsDirectoryPath), "logs"); assert.equal(first.config.observability.localTracingEnabled, true); assert.equal(first.config.observability.otlpTracesUrl, "http://localhost:4318/v1/traces"); assert.equal(first.config.observability.otlpTracesEnabled, true); @@ -4715,6 +5054,51 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("refreshes providers for each subscribeServerConfig connection", () => + Effect.gen(function* () { + const refreshCalls = yield* Ref.make(0); + const firstRefreshDone = yield* Deferred.make(); + const secondRefreshDone = yield* Deferred.make(); + + yield* buildAppUnderTest({ + layers: { + providerRegistry: { + refresh: () => + Ref.updateAndGet(refreshCalls, (count) => count + 1).pipe( + Effect.tap((count) => + Deferred.succeed( + count === 1 ? firstRefreshDone : secondRefreshDone, + undefined, + ).pipe(Effect.ignore), + ), + Effect.as([]), + ), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + Effect.gen(function* () { + yield* client[WS_METHODS.subscribeServerConfig]({}).pipe(Stream.runHead); + yield* Deferred.await(firstRefreshDone); + }), + ), + ); + yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + Effect.gen(function* () { + yield* client[WS_METHODS.subscribeServerConfig]({}).pipe(Stream.runHead); + yield* Deferred.await(secondRefreshDone); + }), + ), + ); + + assert.equal(yield* Ref.get(refreshCalls), 2); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("routes websocket resource telemetry through the subscription", () => Effect.gen(function* () { yield* buildAppUnderTest(); @@ -4732,6 +5116,84 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + // An already-shipped client decodes this stream against an event union + // without environmentThemesUpdated, so an ungated emit would kill its whole + // config subscription. Opting in is the only way to receive them. + it.effect("subscribeServerConfig sends published themes to an opt-in subscriber", () => + Effect.gen(function* () { + const themes = [ + { + id: "nightfall", + name: "Nightfall", + appearance: "dark" as const, + canvas: "#1a1b26", + accent: "#7aa2f7", + }, + ] as const; + + yield* buildAppUnderTest({ + layers: { + environmentTheme: { + current: Effect.succeed(themes), + streamChanges: Stream.succeed(themes), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const events = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.subscribeServerConfig]({ environmentThemes: true }).pipe( + Stream.take(2), + Stream.runCollect, + ), + ), + ); + + const [first, second] = Array.from(events); + assert.equal(first?.type, "snapshot"); + // Not in the snapshot as well, or every opt-in client receives the same + // array twice on every connect. + if (first?.type === "snapshot") assert.equal(first.config.environmentThemes, undefined); + assert.equal(second?.type, "environmentThemesUpdated"); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("subscribeServerConfig withholds published themes from other subscribers", () => + Effect.gen(function* () { + const themes = [ + { + id: "nightfall", + name: "Nightfall", + appearance: "dark" as const, + canvas: "#1a1b26", + accent: "#7aa2f7", + }, + ] as const; + + yield* buildAppUnderTest({ + layers: { + environmentTheme: { + current: Effect.succeed(themes), + streamChanges: Stream.succeed(themes), + }, + providerRegistry: { streamChanges: Stream.empty }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const events = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.subscribeServerConfig]({}).pipe(Stream.take(1), Stream.runCollect), + ), + ); + + const first = Array.from(events)[0]; + assert.equal(first?.type, "snapshot"); + if (first?.type === "snapshot") assert.equal(first.config.environmentThemes, undefined); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("routes websocket rpc subscribeServerConfig emits provider status updates", () => Effect.gen(function* () { const nextProviders = [ @@ -5209,7 +5671,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { createdAt: "2026-01-01T00:00:00.000Z", }) as const; - const wsUrl = yield* getWsServerUrl("/ws?clientSurface=mobile&clientAppVersion=1.2.3"); + const wsUrl = yield* getWsServerUrl( + "/ws?clientSurface=mobile&clientAppVersion=1.2.3&clientDeviceType=phone&clientOs=iOS&clientOsMajorVersion=18&clientDeviceModel=iPhone+15+Pro&connectionMethod=relay", + ); yield* Effect.scoped( withWsRpcClient(wsUrl, (client) => Effect.gen(function* () { @@ -5242,22 +5706,157 @@ it.layer(NodeServices.layer)("server router seam", (it) => { "analytics:client.thread.started", ]); assert.deepEqual(analyticsProperties, [ - { surface: "mobile", appVersion: "1.2.3" }, - { surface: "mobile", appVersion: "1.2.3" }, + { + surface: "mobile", + appVersion: "1.2.3", + clientAppVersion: "1.2.3", + clientOs: "iOS", + os: "iOS", + clientDeviceType: "phone", + osMajorVersion: 18, + clientOsMajorVersion: 18, + deviceModel: "iPhone 15 Pro", + clientDeviceModel: "iPhone 15 Pro", + connectionMethod: "relay", + }, + { + surface: "mobile", + appVersion: "1.2.3", + clientAppVersion: "1.2.3", + clientOs: "iOS", + os: "iOS", + clientDeviceType: "phone", + osMajorVersion: 18, + clientOsMajorVersion: 18, + deviceModel: "iPhone 15 Pro", + clientDeviceModel: "iPhone 15 Pro", + connectionMethod: "relay", + }, ]); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); - it.effect("routes websocket rpc projects.writeFile errors", () => + it.effect("keeps telemetry separate for simultaneous clients", () => Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const workspaceDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-ws-project-write-" }); + const analyticsEvents: Array<{ + event: string; + properties: Readonly> | undefined; + }> = []; - yield* buildAppUnderTest(); + yield* buildAppUnderTest({ + layers: { + analyticsService: { + record: (event, properties) => + Effect.sync(() => analyticsEvents.push({ event, properties })), + }, + orchestrationEngine: { + dispatch: () => Effect.succeed({ sequence: 1 }), + }, + }, + }); - const wsUrl = yield* getWsServerUrl("/ws"); - const result = yield* Effect.scoped( - withWsRpcClient(wsUrl, (client) => + const webUrl = yield* getWsServerUrl( + "/ws?clientSurface=web&clientAppVersion=2.0.0&clientDeviceType=desktop&clientOs=Windows&clientWebDeployment=hosted&clientBrowser=Chrome&connectionMethod=direct", + ); + const mobileUrl = yield* getWsServerUrl( + "/ws?clientSurface=mobile&clientAppVersion=3.0.0&clientDeviceType=tablet&clientOs=Android&clientOsMajorVersion=15&clientDeviceModel=Pixel+Tablet&connectionMethod=relay", + ); + const turnCommand = (client: string) => ({ + type: "thread.turn.start" as const, + commandId: CommandId.make(`cmd-${client}-turn`), + threadId: ThreadId.make(`thread-${client}`), + message: { + messageId: MessageId.make(`message-${client}`), + role: "user" as const, + text: "hello", + attachments: [], + }, + modelSelection: defaultModelSelection, + runtimeMode: "full-access" as const, + interactionMode: "default" as const, + createdAt: "2026-01-01T00:00:00.000Z", + }); + + yield* Effect.scoped( + withWsRpcClient(webUrl, (webClient) => + withWsRpcClient(mobileUrl, (mobileClient) => + Effect.gen(function* () { + yield* mobileClient[ORCHESTRATION_WS_METHODS.dispatchCommand](turnCommand("mobile")); + yield* webClient[ORCHESTRATION_WS_METHODS.dispatchCommand](turnCommand("web")); + }), + ), + ), + ); + + assert.deepEqual( + analyticsEvents + .filter(({ event }) => event === "client.turn.requested") + .map(({ properties }) => properties), + [ + { + surface: "mobile", + appVersion: "3.0.0", + clientAppVersion: "3.0.0", + clientOs: "Android", + os: "Android", + clientDeviceType: "tablet", + osMajorVersion: 15, + clientOsMajorVersion: 15, + deviceModel: "Pixel Tablet", + clientDeviceModel: "Pixel Tablet", + connectionMethod: "relay", + }, + { + surface: "web", + appVersion: "2.0.0", + clientAppVersion: "2.0.0", + clientOs: "Windows", + clientDeviceType: "desktop", + webDeployment: "hosted", + clientBrowser: "Chrome", + connectionMethod: "direct", + }, + ], + ); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("ignores invalid client telemetry without rejecting the connection", () => + Effect.gen(function* () { + const connectedProperties: Array> | undefined> = []; + + yield* buildAppUnderTest({ + layers: { + analyticsService: { + record: (event, properties) => + event === "client.connected" + ? Effect.sync(() => connectedProperties.push(properties)) + : Effect.void, + }, + }, + }); + + const invalidUrl = yield* getWsServerUrl( + "/ws?clientSurface=watch&clientDeviceType=television&clientOs=Plan9&clientWebDeployment=cdn&clientBrowser=&clientOsMajorVersion=-1&connectionMethod=teleport", + ); + yield* Effect.scoped( + withWsRpcClient(invalidUrl, (client) => client[WS_METHODS.serverGetSettings]({})), + ); + + assert.deepEqual(connectedProperties, [{}]); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("routes websocket rpc projects.writeFile errors", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const workspaceDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-ws-project-write-" }); + + yield* buildAppUnderTest(); + + const wsUrl = yield* getWsServerUrl("/ws"); + const result = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => client[WS_METHODS.projectsWriteFile]({ cwd: workspaceDir, relativePath: "../escape.txt", @@ -6362,6 +6961,206 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest), TestClock.withLive), ); + it.effect("coalesces buffered live tool updates to the latest state", () => + Effect.gen(function* () { + const thread = makeDefaultOrchestrationReadModel().threads[0]!; + const liveEvents = yield* PubSub.unbounded(); + + yield* buildAppUnderTest({ + layers: { + orchestrationEngine: { + streamDomainEvents: Stream.fromPubSub(liveEvents), + }, + projectionSnapshotQuery: { + getThreadDetailSnapshot: () => + Effect.gen(function* () { + yield* Effect.sleep("25 millis"); + yield* PubSub.publishAll(liveEvents, [ + makeLiveToolActivityEvent(2), + makeLiveToolActivityEvent(3), + makeLiveToolActivityEvent(4), + ]); + return Option.some({ snapshotSequence: 1, thread }); + }), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const items = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.subscribeThread]({ + threadId: defaultThreadId, + }).pipe(Stream.take(2), Stream.runCollect), + ), + ).pipe(Effect.timeout("2 seconds")); + + assert.equal(items[0]?.kind, "snapshot"); + assert.equal(items[1]?.kind, "event"); + assert.equal(items[1]?.kind === "event" ? items[1].event.sequence : null, 4); + }).pipe(Effect.provide(NodeHttpServer.layerTest), TestClock.withLive), + ); + + it.effect("flushes more than one tool chunk before the synchronization marker", () => + Effect.gen(function* () { + const thread = makeDefaultOrchestrationReadModel().threads[0]!; + const liveEvents = yield* PubSub.unbounded(); + + yield* buildAppUnderTest({ + layers: { + orchestrationEngine: { + streamDomainEvents: Stream.fromPubSub(liveEvents), + }, + projectionSnapshotQuery: { + getThreadDetailSnapshot: () => + Effect.gen(function* () { + yield* Effect.sleep("25 millis"); + yield* PubSub.publishAll(liveEvents, [ + ...Array.from({ length: 512 }, (_, index) => + makeLiveToolActivityEvent(index + 2), + ), + makeLiveToolActivityEvent(514, "tool.updated", { + toolCallId: "call-read", + title: "Reading server.test.ts", + path: "apps/server/src/server.test.ts", + }), + ]); + return Option.some({ snapshotSequence: 1, thread }); + }), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const items = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.subscribeThread]({ + threadId: defaultThreadId, + requestCompletionMarker: true, + }).pipe(Stream.take(4), Stream.runCollect), + ), + ).pipe(Effect.timeout("2 seconds")); + + assert.equal(items[0]?.kind, "snapshot"); + assert.deepEqual( + items.slice(1, 3).map((item) => { + assert.equal(item?.kind, "event"); + if (item?.kind !== "event" || item.event.type !== "thread.activity-appended") { + return null; + } + return { + sequence: item.event.sequence, + summary: item.event.payload.activity.summary, + payload: item.event.payload.activity.payload, + }; + }), + [ + { + sequence: 513, + summary: "Editing app.ts", + payload: { + itemType: "file_change", + title: "Editing app.ts", + data: { + files: [{ path: "src/app.ts" }], + toolCallId: "call-edit", + }, + }, + }, + { + sequence: 514, + summary: "Reading server.test.ts", + payload: { + itemType: "file_change", + title: "Reading server.test.ts", + data: { + files: [{ path: "apps/server/src/server.test.ts" }], + toolCallId: "call-read", + }, + }, + }, + ], + ); + assert.deepEqual(items[3], { kind: "synchronized" }); + }).pipe(Effect.provide(NodeHttpServer.layerTest), TestClock.withLive), + ); + + it.effect("flushes a tool update before an interleaved message", () => + Effect.gen(function* () { + const thread = makeDefaultOrchestrationReadModel().threads[0]!; + const liveEvents = yield* PubSub.unbounded(); + const messageEvent = { + sequence: 3, + eventId: EventId.make("event-interleaved-message"), + aggregateKind: "thread", + aggregateId: defaultThreadId, + occurredAt: "2026-01-01T00:00:02.000Z", + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + type: "thread.message-sent", + payload: { + threadId: defaultThreadId, + messageId: MessageId.make("message-interleaved"), + role: "assistant", + text: "Still working", + turnId: TurnId.make("turn-edit"), + streaming: false, + createdAt: "2026-01-01T00:00:02.000Z", + updatedAt: "2026-01-01T00:00:02.000Z", + }, + } satisfies Extract; + + yield* buildAppUnderTest({ + layers: { + orchestrationEngine: { + streamDomainEvents: Stream.fromPubSub(liveEvents), + }, + projectionSnapshotQuery: { + getThreadDetailSnapshot: () => + Effect.gen(function* () { + yield* Effect.sleep("25 millis"); + yield* PubSub.publishAll(liveEvents, [ + makeLiveToolActivityEvent(2), + messageEvent, + makeLiveToolActivityEvent(4, "tool.completed"), + ]); + return Option.some({ snapshotSequence: 1, thread }); + }), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const items = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.subscribeThread]({ + threadId: defaultThreadId, + }).pipe(Stream.take(4), Stream.runCollect), + ), + ).pipe(Effect.timeout("2 seconds")); + + assert.equal(items[0]?.kind, "snapshot"); + assert.deepEqual( + items + .slice(1) + .map((item) => (item.kind === "event" ? [item.event.sequence, item.event.type] : null)), + [ + [2, "thread.activity-appended"], + [3, "thread.message-sent"], + [4, "thread.activity-appended"], + ], + ); + assert.equal( + items[3]?.kind === "event" && items[3].event.type === "thread.activity-appended" + ? items[3].event.payload.activity.kind + : null, + "tool.completed", + ); + }).pipe(Effect.provide(NodeHttpServer.layerTest), TestClock.withLive), + ); + it.effect("subscribeThread sends a fresh snapshot instead of replaying a large gap", () => Effect.gen(function* () { let readEventsCalls = 0; @@ -7240,7 +8039,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); - it.effect("stops the provider session after settle without closing terminals", () => + it.effect("leaves settle cleanup to the event reactor", () => Effect.gen(function* () { const threadId = ThreadId.make("thread-settle"); const effects: string[] = []; @@ -7298,64 +8097,40 @@ it.layer(NodeServices.layer)("server router seam", (it) => { ); assert.equal(dispatchResult.sequence, 1); - assert.deepEqual(effects, ["dispatch:thread.settle", "dispatch:thread.session.stop"]); - const sessionStopCommand = dispatchedCommands[1]; - assert.equal(sessionStopCommand?.type, "thread.session.stop"); - if (sessionStopCommand?.type === "thread.session.stop") { - assert.equal(sessionStopCommand.threadId, threadId); - assert.equal(sessionStopCommand.commandId, "session-stop-for-settle:cmd-thread-settle"); - assert.equal(sessionStopCommand.onlyIfSettled, true); - } + assert.deepEqual(effects, ["dispatch:thread.settle"]); + assert.deepEqual( + dispatchedCommands.map((command) => command.type), + ["thread.settle"], + ); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); - it.effect("settles without dispatching session stop when the thread has no session", () => + it.effect("forwards the friendly blocked-settlement message over websocket rpc", () => Effect.gen(function* () { - const threadId = ThreadId.make("thread-settle-no-session"); - const effects: string[] = []; - const dispatchedCommands: Array = []; - + const threadId = ThreadId.make("thread-settle-blocked"); yield* buildAppUnderTest({ layers: { - terminalManager: { - close: (input) => - Effect.sync(() => { - effects.push(`terminal.close:${input.threadId}`); - }), - }, orchestrationEngine: { - dispatch: (command) => - Effect.sync(() => { - dispatchedCommands.push(command); - effects.push(`dispatch:${command.type}`); - return { sequence: dispatchedCommands.length }; - }), - }, - projectionSnapshotQuery: { - getThreadShellById: () => - Effect.succeed( - Option.some(makeDefaultOrchestrationThreadShell({ id: threadId, session: null })), - ), + dispatch: () => Effect.fail(new OrchestrationThreadSettleBlockedError({ threadId })), }, }, }); const wsUrl = yield* getWsServerUrl("/ws"); - const dispatchResult = yield* Effect.scoped( + const error = yield* Effect.scoped( withWsRpcClient(wsUrl, (client) => client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ type: "thread.settle", - commandId: CommandId.make("cmd-thread-settle-no-session"), + commandId: CommandId.make("cmd-thread-settle-blocked"), threadId, }), - ), + ).pipe(Effect.flip), ); - assert.equal(dispatchResult.sequence, 1); - assert.deepEqual(effects, ["dispatch:thread.settle"]); - assert.deepEqual( - dispatchedCommands.map((command) => command.type), - ["thread.settle"], + assert.equal(error._tag, "OrchestrationDispatchCommandError"); + assert.equal( + error.message, + "This thread still needs attention. Resolve or interrupt it first, then try again.", ); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); @@ -8145,6 +8920,111 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("drains deletion cleanup through the re-created thread event", () => + Effect.gen(function* () { + // A draft retry reuses the thread id its failed bootstrap deleted. The + // deletion reactor stops sessions and closes terminals by that id, so + // both thread.create paths use the created event as a fence, then drain + // cleanup before handing the new incarnation to resource-owning work. + const trace: Array = []; + const drainRequested = yield* Deferred.make(); + const cleanupDone = yield* Deferred.make(); + yield* buildAppUnderTest({ + layers: { + threadDeletionReactor: { + drainThrough: (sequence) => + Effect.gen(function* () { + trace.push(`drain:${sequence}`); + yield* Deferred.succeed(drainRequested, undefined); + yield* Deferred.await(cleanupDone); + }), + }, + orchestrationEngine: { + dispatch: (command) => + Effect.sync(() => { + trace.push(command.type); + return { sequence: trace.length }; + }), + readEvents: () => Stream.empty, + }, + }, + }); + + const createdAt = "2026-01-01T00:00:00.000Z"; + const threadId = ThreadId.make("thread-retry-after-delete"); + const wsUrl = yield* getWsServerUrl("/ws"); + + yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + Effect.gen(function* () { + const directCreate = yield* Effect.forkChild( + client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ + type: "thread.create", + commandId: CommandId.make("cmd-retry-create"), + threadId, + projectId: defaultProjectId, + title: "Retry", + modelSelection: defaultModelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt, + }), + ); + yield* Deferred.await(drainRequested); + assert.deepEqual(trace, ["thread.create", "drain:1"]); + yield* Deferred.succeed(cleanupDone, undefined); + yield* Fiber.join(directCreate); + }), + ), + ); + assert.deepEqual(trace, ["thread.create", "drain:1"]); + + // Cleanup is already released; the bootstrap path must still drain + // between creating the thread and starting its turn. + trace.length = 0; + yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + Effect.gen(function* () { + const bootstrapCreate = yield* Effect.forkChild( + client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-retry-bootstrap"), + threadId, + message: { + messageId: MessageId.make("msg-retry-bootstrap"), + role: "user", + text: "hello", + attachments: [], + }, + modelSelection: defaultModelSelection, + runtimeMode: "full-access", + interactionMode: "default", + bootstrap: { + createThread: { + projectId: defaultProjectId, + title: "Retry", + modelSelection: defaultModelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt, + }, + runSetupScript: false, + }, + createdAt, + }), + ); + yield* Fiber.join(bootstrapCreate); + }), + ), + ); + assert.deepEqual(trace, ["thread.create", "drain:1", "thread.turn.start"]); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("does not report a deleted bootstrap thread when cleanup fails", () => Effect.gen(function* () { const dispatchedCommands: Array = []; diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 0a31bf376dae..631902ac087d 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -32,6 +32,7 @@ import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory.ts"; import * as ProviderSessionRuntime from "./persistence/ProviderSessionRuntime.ts"; import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry.ts"; +import * as ModelManifest from "./provider/ModelManifest.ts"; import * as ProviderEventLoggers from "./provider/Layers/ProviderEventLoggers.ts"; import { ProviderServiceLive } from "./provider/Layers/ProviderService.ts"; import { ProviderSessionReaperLive } from "./provider/Layers/ProviderSessionReaper.ts"; @@ -52,6 +53,7 @@ import * as PreviewManager from "./preview/Manager.ts"; import * as PortScanner from "./preview/PortScanner.ts"; import * as ProcessRunner from "./processRunner.ts"; import * as GitManager from "./git/GitManager.ts"; +import * as EnvironmentTheme from "./environmentTheme.ts"; import * as Keybindings from "./keybindings.ts"; import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; import { OrchestrationReactorLive } from "./orchestration/Layers/OrchestrationReactor.ts"; @@ -60,6 +62,7 @@ import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRun import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderCommandReactor.ts"; import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor.ts"; import { ThreadDeletionReactorLive } from "./orchestration/Layers/ThreadDeletionReactor.ts"; +import * as ThreadSettlementReactor from "./orchestration/ThreadSettlementReactor.ts"; import * as AgentAwarenessRelay from "./relay/AgentAwarenessRelay.ts"; import { hasCloudPublicConfig } from "./cloud/publicConfig.ts"; import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry.ts"; @@ -121,6 +124,12 @@ import * as RelayClient from "@t3tools/shared/relayClient"; import { disableTailscaleServe, ensureTailscaleServe } from "@t3tools/tailscale"; import { forkParked, ServerActivation } from "./serverActivation.ts"; +// MCP handoff thread IDs include escaped provenance and can exceed find-my-way's +// 100-character default for one path segment. +export const HTTP_ROUTER_CONFIG = { + maxParamLength: 512, +} as const; + // Effect's default preemptive shutdown waits 20s before finalizing request scopes. // T3's primary transport is long-lived WebSocket RPC, whose Effect scope finalizer // already closes the websocket gracefully. Do not add an artificial drain before @@ -143,7 +152,10 @@ const PtyAdapterLive = Layer.unwrap( }), ); -const ServerSettingsLayerLive = ServerSettings.layer.pipe(Layer.provide(ServerSecretStore.layer)); +const ServerSettingsLayerLive = ServerSettings.layer.pipe( + Layer.provide(ServerSecretStore.layer), + Layer.provideMerge(SqlitePersistenceLayerLive), +); const NativeTelemetryLayerLive = NativeTelemetryClient.layer.pipe( Layer.provide(ResourceMonitorBinary.layer), @@ -246,6 +258,7 @@ const ReactorLayerLive = Layer.empty.pipe( Layer.provideMerge(ProviderCommandReactorLive), Layer.provideMerge(CheckpointReactorLive), Layer.provideMerge(ThreadDeletionReactorLive), + Layer.provideMerge(ThreadSettlementReactor.layer), Layer.provideMerge(AgentAwarenessRelay.layer.pipe(Layer.provide(ServerSecretStore.layer))), Layer.provideMerge(RuntimeReceiptBusLive), ); @@ -279,6 +292,13 @@ const SourceControlProviderRegistryLayerLive = SourceControlProviderRegistry.lay Layer.provideMerge(VcsDriverRegistryLayerLive), ); +const PullRequestServiceLive = PullRequestService.layer.pipe( + Layer.provide(PullRequestProviderRegistry.layer), + Layer.provide(SourceControlProviderRegistryLayerLive), + Layer.provide(SourceControlRateLimit.layer), + Layer.provide(VcsProcess.layer), +); + const GitManagerLayerLive = GitManager.layer.pipe( Layer.provideMerge(ProjectSetupScriptRunner.layer), Layer.provideMerge(GitVcsDriver.layer), @@ -351,8 +371,13 @@ const ProjectFaviconResolverLayerLive = ProjectFaviconResolver.layer.pipe( Layer.provide(T3ProjectFileLoader.layer), ); +const ServerEnvironmentLayerLive = ServerEnvironment.layer.pipe( + Layer.provide(ServerSecretStore.layer), +); + const AuthLayerLive = EnvironmentAuth.layer.pipe( Layer.provideMerge(PersistenceLayerLive), + Layer.provide(ServerEnvironmentLayerLive), Layer.provide(ServerSecretStore.layer), ); @@ -373,13 +398,17 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( // Core Services Layer.provideMerge(ServerSettingsLayerLive), Layer.provideMerge(CheckpointingLayerLive), - Layer.provideMerge(SourceControlProviderRegistryLayerLive), + Layer.provideMerge( + Layer.mergeAll(SourceControlProviderRegistryLayerLive, PullRequestServiceLive), + ), Layer.provideMerge(GitLayerLive), Layer.provideMerge(VcsLayerLive), Layer.provideMerge(ProviderRuntimeLayerLive), Layer.provideMerge(Layer.mergeAll(TerminalLayerLive, PreviewLayerLive)), Layer.provideMerge(PersistenceLayerLive), - Layer.provideMerge(Keybindings.layer), + // Both read a user-owned file out of the state directory and stream changes + // to clients; neither depends on the other. + Layer.provideMerge(Layer.mergeAll(Keybindings.layer, EnvironmentTheme.layer)), Layer.provideMerge(ProviderRegistryLive), // The instance registry is the new routing keystone — text generation, // adapter lookup, and runtime ingestion all resolve `ProviderInstanceId` @@ -392,7 +421,10 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( // `ProviderService` (canonical stream, written after event normalization). // Provided once at the runtime level so every consumer sees the same // logger instances. - Layer.provideMerge(ProviderEventLoggers.layer), + // `ModelManifest.layer` is the legacy-model classification data, refreshed + // from the repo's `model-manifest.json` on `main` and applied by the + // Codex/Claude drivers. + Layer.provideMerge(Layer.mergeAll(ProviderEventLoggers.layer, ModelManifest.layer)), // `OpenCodeDriver.create()` yields `OpenCodeRuntime`; previously the old // `ProviderRegistryLive` pulled `OpenCodeRuntimeLive` in for itself, but // the rewritten registry reads snapshots off the instance registry and @@ -402,7 +434,7 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( Layer.provideMerge(WorkspaceLayerLive), Layer.provideMerge(ProjectFaviconResolverLayerLive), Layer.provideMerge(RepositoryIdentityResolver.layer), - Layer.provideMerge(ServerEnvironment.layer), + Layer.provideMerge(ServerEnvironmentLayerLive), Layer.provideMerge(AuthLayerLive), Layer.provideMerge(ServerSecretStore.layer), Layer.provideMerge( @@ -437,14 +469,6 @@ const commandReadinessLayer = HttpRouter.middleware( { global: true }, ); -const PullRequestServiceLive = PullRequestService.layer.pipe( - // One registry entry per supported host; the service only knows the registry. - Layer.provide(PullRequestProviderRegistry.layer), - Layer.provide(SourceControlProviderRegistryLayerLive), - Layer.provide(SourceControlRateLimit.layer), - Layer.provide(VcsProcess.layer), -); - export const makeRoutesLayer = Layer.mergeAll( Layer.mergeAll( HttpApiBuilder.layer(EnvironmentHttpApi).pipe( @@ -665,6 +689,7 @@ export const makeServerLayer = Layer.unwrap( const routesLayer = HttpRouter.serve(makeRoutesLayer.pipe(Layer.provide(launcherLayer)), { disableLogger: !config.logWebSocketEvents, + routerConfig: HTTP_ROUTER_CONFIG, }).pipe(Layer.tap(() => Deferred.succeed(routesReady, undefined).pipe(Effect.orDie))); const serverApplicationLayer = Layer.mergeAll( routesLayer, diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts index e3f7e482b2e0..fc5b2c9e545b 100644 --- a/apps/server/src/serverRuntimeStartup.test.ts +++ b/apps/server/src/serverRuntimeStartup.test.ts @@ -74,6 +74,7 @@ it.effect("launchStartupHeartbeat does not block the caller while counts are loa Effect.scoped( Effect.gen(function* () { const releaseCounts = yield* Deferred.make(); + const countsStarted = yield* Deferred.make(); yield* ServerRuntimeStartup.launchStartupHeartbeat.pipe( Effect.provideService(ProjectionSnapshotQuery.ProjectionSnapshotQuery, { @@ -83,7 +84,8 @@ it.effect("launchStartupHeartbeat does not block the caller while counts are loa getArchivedShellSnapshot: () => Effect.die("unused"), getSnapshotSequence: () => Effect.die("unused"), getCounts: () => - Deferred.await(releaseCounts).pipe( + Deferred.succeed(countsStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseCounts)), Effect.as({ projectCount: 2, threadCount: 3, @@ -104,6 +106,13 @@ it.effect("launchStartupHeartbeat does not block the caller while counts are loa flush: Effect.void, }), ); + + // The heartbeat is forked, so the caller is already back here while + // getCounts is still parked. Awaiting countsStarted proves the forked + // work really ran; releaseCounts staying incomplete proves the caller + // never waited for it. + yield* Deferred.await(countsStarted); + assert.equal(yield* Deferred.isDone(releaseCounts), false); }), ), ); diff --git a/apps/server/src/serverRuntimeState.ts b/apps/server/src/serverRuntimeState.ts index b32f3814547c..c08e5a82902e 100644 --- a/apps/server/src/serverRuntimeState.ts +++ b/apps/server/src/serverRuntimeState.ts @@ -104,6 +104,21 @@ export const clearPersistedServerRuntimeState = (path: string) => ); }); +/** + * Report whether the pid recorded in a persisted runtime state is still + * running. Signal 0 delivers nothing; it only reports whether the pid exists. + * EPERM means it exists but belongs to another user, which still counts as + * alive. + */ +export const isProcessAlive = (pid: number): boolean => { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return error instanceof Error && "code" in error && error.code === "EPERM"; + } +}; + export const readPersistedServerRuntimeState = (path: string) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 35ef5e976223..82ed8525b9b5 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -3,6 +3,7 @@ import { DEFAULT_SERVER_SETTINGS, ProviderDriverKind, ProviderInstanceId, + resolveProviderInstanceEnabled, ServerSettings, ServerSettingsPatch, } from "@t3tools/contracts"; @@ -16,8 +17,10 @@ import * as Option from "effect/Option"; import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; import * as ServerConfig from "./config.ts"; +import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite.ts"; import * as ServerSettingsModule from "./serverSettings.ts"; const decodeSettingsPatch = Schema.decodeUnknownEffect(ServerSettingsPatch); @@ -26,6 +29,7 @@ const decodeServerSettings = Schema.decodeUnknownEffect(ServerSettings); const makeServerSettingsLayer = () => ServerSettingsModule.layer.pipe( Layer.provide(ServerSecretStore.layer), + Layer.provideMerge(Layer.fresh(SqlitePersistenceMemory)), Layer.provideMerge( Layer.fresh( ServerConfig.layerTest(process.cwd(), { @@ -47,6 +51,27 @@ const makeFailingSecretStoreLayer = (cause: ServerSecretStore.SecretStoreError) }), ); +const recordProviderUsage = (provider: string, instanceId: string | null = provider) => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql` + INSERT INTO projection_thread_sessions ( + thread_id, + status, + provider_name, + provider_instance_id, + updated_at + ) + VALUES ( + ${`thread-${instanceId ?? provider}`}, + ${"ready"}, + ${provider}, + ${instanceId}, + ${"2026-08-25T00:00:00.000Z"} + ) + `; + }); + it.layer(NodeServices.layer)("server settings", (it) => { it.effect("preserves context when reading a provider environment secret fails", () => { const platformCause = PlatformError.systemError({ @@ -67,6 +92,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { ); const settingsLayer = ServerSettingsModule.layer.pipe( Layer.provide(makeFailingSecretStoreLayer(cause)), + Layer.provideMerge(Layer.fresh(SqlitePersistenceMemory)), Layer.provideMerge(configLayer), ); @@ -92,6 +118,23 @@ it.layer(NodeServices.layer)("server settings", (it) => { }).pipe(Effect.provide(settingsLayer)); }); + it.effect("identifies provider history query failures", () => + Effect.gen(function* () { + const serverConfig = yield* ServerConfig.ServerConfig; + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + const sql = yield* SqlClient.SqlClient; + yield* sql`DROP TABLE projection_thread_sessions`; + + const error = yield* Effect.flip(serverSettings.getSettings); + + assert.deepInclude(error, { + _tag: "ServerSettingsError", + operation: "read-provider-history", + settingsPath: serverConfig.settingsPath, + }); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); + it.effect("decodes nested settings patches", () => Effect.gen(function* () { assert.deepEqual( @@ -190,6 +233,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { homePath: "", customModels: ["claude-custom"], launchArgs: "", + autoCompactWindow: "", }); assert.deepEqual( next.textGenerationModelSelection, @@ -228,6 +272,34 @@ it.layer(NodeServices.layer)("server settings", (it) => { ).pipe(Effect.provide(makeServerSettingsLayer())), ); + it.effect("persists and broadcasts thread settlement settings", () => + Effect.scoped( + Effect.gen(function* () { + const serverConfig = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + const changes = yield* serverSettings.subscribeChanges; + + const next = yield* serverSettings.updateSettings({ + sidebarAutoSettleAfterDays: null, + sidebarAutoSettleOnMerge: false, + }); + const change = Option.getOrUndefined(yield* Stream.runHead(changes)); + const raw = yield* fileSystem.readFileString(serverConfig.settingsPath); + // Inspect raw persisted JSON before schema decoding can apply defaults. + // @effect-diagnostics-next-line preferSchemaOverJson:off + const persisted = JSON.parse(raw) as Record; + + assert.strictEqual(next.sidebarAutoSettleAfterDays, null); + assert.isFalse(next.sidebarAutoSettleOnMerge); + assert.strictEqual(change?.sidebarAutoSettleAfterDays, null); + assert.isFalse(change?.sidebarAutoSettleOnMerge); + assert.strictEqual(persisted.sidebarAutoSettleAfterDays, null); + assert.isFalse(persisted.sidebarAutoSettleOnMerge); + }), + ).pipe(Effect.provide(makeServerSettingsLayer())), + ); + it.effect("preserves model when switching providers via textGenerationModelSelection", () => Effect.gen(function* () { const serverSettings = yield* ServerSettingsModule.ServerSettingsService; @@ -487,6 +559,251 @@ it.layer(NodeServices.layer)("server settings", (it) => { }).pipe(Effect.provide(makeServerSettingsLayer())), ); + it.effect("enables previously used providers from sparse settings files", () => + Effect.gen(function* () { + const serverConfig = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + yield* fileSystem.writeFileString( + serverConfig.settingsPath, + '{"providers":{"opencode":{"serverUrl":"http://127.0.0.1:4096"}}}', + ); + yield* recordProviderUsage("opencode"); + + const settings = yield* serverSettings.getSettings; + + assert.isFalse(settings.providers.grok.enabled); + assert.isTrue(settings.providers.opencode.enabled); + assert.isFalse(settings.providers.cursor.enabled); + assert.equal(settings.providers.opencode.serverUrl, "http://127.0.0.1:4096"); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); + + it.effect("preserves existing provider instances without explicit enabled flags", () => + Effect.gen(function* () { + const serverConfig = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + yield* fileSystem.writeFileString( + serverConfig.settingsPath, + '{"providerInstances":{"cursor_work":{"driver":"cursor","config":{}},"grok":{"driver":"grok","config":{}},"opencode_work":{"driver":"opencode","config":{"serverUrl":"http://127.0.0.1:4096"}},"opencode_unused":{"driver":"opencode","config":{}}}}', + ); + yield* recordProviderUsage("cursor", "cursor_work"); + yield* recordProviderUsage("grok", null); + yield* recordProviderUsage("opencode", "opencode_work"); + + const settings = yield* serverSettings.getSettings; + + assert.isTrue(settings.providers.cursor.enabled); + assert.isTrue(settings.providerInstances[ProviderInstanceId.make("cursor_work")]?.enabled); + assert.isTrue(settings.providerInstances[ProviderInstanceId.make("grok")]?.enabled); + assert.isTrue(settings.providerInstances[ProviderInstanceId.make("opencode_work")]?.enabled); + const unused = settings.providerInstances[ProviderInstanceId.make("opencode_unused")]; + assert.isDefined(unused); + assert.isFalse(resolveProviderInstanceEnabled(unused)); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); + + it.effect("preserves explicit provider disables in existing settings files", () => + Effect.gen(function* () { + const serverConfig = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + yield* fileSystem.writeFileString( + serverConfig.settingsPath, + '{"providers":{"grok":{"enabled":false},"opencode":{"enabled":false},"cursor":{"enabled":false}},"providerInstances":{"grok":{"driver":"grok","enabled":false,"config":{}},"opencode":{"driver":"opencode","config":{"enabled":false}},"cursor":{"driver":"cursor","enabled":false,"config":{}}}}', + ); + yield* recordProviderUsage("grok"); + yield* recordProviderUsage("opencode"); + yield* recordProviderUsage("cursor"); + + const settings = yield* serverSettings.getSettings; + + assert.isFalse(settings.providers.grok.enabled); + assert.isFalse(settings.providers.opencode.enabled); + assert.isFalse(settings.providers.cursor.enabled); + assert.isFalse(settings.providerInstances[ProviderInstanceId.make("grok")]?.enabled); + assert.isFalse(settings.providerInstances[ProviderInstanceId.make("opencode")]?.enabled); + assert.isFalse(settings.providerInstances[ProviderInstanceId.make("cursor")]?.enabled); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); + + it.effect("keeps unused providers disabled in existing sparse settings files", () => + Effect.gen(function* () { + const serverConfig = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + yield* fileSystem.writeFileString(serverConfig.settingsPath, "{}"); + + const settings = yield* serverSettings.getSettings; + + assert.isFalse(settings.providers.grok.enabled); + assert.isFalse(settings.providers.opencode.enabled); + assert.isFalse(settings.providers.cursor.enabled); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); + + it.effect("preserves provider history when no settings file exists", () => + Effect.gen(function* () { + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + yield* recordProviderUsage("grok"); + + const settings = yield* serverSettings.getSettings; + + assert.isTrue(settings.providers.grok.enabled); + assert.isFalse(settings.providers.opencode.enabled); + assert.isFalse(settings.providers.cursor.enabled); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); + + it.effect("preserves provider history when the settings file is invalid", () => + Effect.gen(function* () { + const serverConfig = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + yield* fileSystem.writeFileString(serverConfig.settingsPath, "{invalid json"); + yield* recordProviderUsage("cursor"); + + const settings = yield* serverSettings.getSettings; + + assert.isTrue(settings.providers.cursor.enabled); + assert.isFalse(settings.providers.grok.enabled); + assert.isFalse(settings.providers.opencode.enabled); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); + + it.effect("preserves valid provider flags when another settings field is invalid", () => + Effect.gen(function* () { + const serverConfig = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + yield* fileSystem.writeFileString( + serverConfig.settingsPath, + '{"addProjectBaseDirectory":42,"providers":{"cursor":{"enabled":false},"grok":{"enabled":true}}}', + ); + yield* recordProviderUsage("cursor"); + + const settings = yield* serverSettings.getSettings; + + assert.isFalse(settings.providers.cursor.enabled); + assert.isTrue(settings.providers.grok.enabled); + assert.isFalse(settings.providers.opencode.enabled); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); + + it.effect("restores providers from persisted runtime sessions", () => + Effect.gen(function* () { + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + const sql = yield* SqlClient.SqlClient; + yield* sql` + INSERT INTO provider_session_runtime ( + thread_id, + provider_name, + provider_instance_id, + adapter_key, + status, + last_seen_at + ) + VALUES ( + ${"thread-opencode-runtime"}, + ${"opencode"}, + ${"opencode"}, + ${"opencode"}, + ${"ready"}, + ${"2026-08-25T00:00:00.000Z"} + ) + `; + + const settings = yield* serverSettings.getSettings; + + assert.isFalse(settings.providers.grok.enabled); + assert.isTrue(settings.providers.opencode.enabled); + assert.isFalse(settings.providers.cursor.enabled); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); + + it.effect("persists explicit disables after a provider has been used", () => + Effect.gen(function* () { + const serverConfig = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + yield* recordProviderUsage("grok"); + + assert.isTrue((yield* serverSettings.getSettings).providers.grok.enabled); + + const settings = yield* serverSettings.updateSettings({ + providers: { grok: { enabled: false } }, + }); + assert.isFalse(settings.providers.grok.enabled); + + const raw = yield* fileSystem.readFileString(serverConfig.settingsPath); + // @effect-diagnostics-next-line preferSchemaOverJson:off + assert.isFalse(JSON.parse(raw).providers.grok.enabled); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); + + it.effect("persists explicit provider enables before their first use", () => + Effect.gen(function* () { + const serverConfig = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + + yield* serverSettings.updateSettings({ + providers: { + cursor: { enabled: true }, + grok: { enabled: true }, + opencode: { enabled: true }, + }, + }); + yield* serverSettings.updateSettings({ addProjectBaseDirectory: "~/Development" }); + + const raw = yield* fileSystem.readFileString(serverConfig.settingsPath); + // @effect-diagnostics-next-line preferSchemaOverJson:off + const persisted = JSON.parse(raw); + assert.isTrue(persisted.providers.cursor.enabled); + assert.isTrue(persisted.providers.grok.enabled); + assert.isTrue(persisted.providers.opencode.enabled); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); + + it.effect("keeps optional providers disabled after a new installation writes settings", () => + Effect.gen(function* () { + const serverConfig = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + + const initial = yield* serverSettings.getSettings; + assert.isFalse(initial.providers.grok.enabled); + assert.isFalse(initial.providers.opencode.enabled); + assert.isFalse(initial.providers.cursor.enabled); + + const next = yield* serverSettings.updateSettings({ + addProjectBaseDirectory: "~/Development", + providerInstances: { + [ProviderInstanceId.make("grok")]: { + driver: ProviderDriverKind.make("grok"), + config: {}, + }, + }, + }); + + assert.isFalse(next.providers.grok.enabled); + assert.isFalse(next.providers.opencode.enabled); + assert.isFalse(next.providers.cursor.enabled); + const grok = next.providerInstances[ProviderInstanceId.make("grok")]; + assert.isDefined(grok); + assert.isFalse(resolveProviderInstanceEnabled(grok)); + + const raw = yield* fileSystem.readFileString(serverConfig.settingsPath); + // @effect-diagnostics-next-line preferSchemaOverJson:off + const persisted = JSON.parse(raw); + assert.isFalse(persisted.providers.cursor.enabled); + assert.isFalse(persisted.providers.grok.enabled); + assert.isFalse(persisted.providers.opencode.enabled); + assert.isUndefined(persisted.providerInstances.grok.enabled); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); + it.effect("folds a legacy in-config enabled flag into the envelope on load", () => Effect.gen(function* () { const serverConfig = yield* ServerConfig.ServerConfig; @@ -581,6 +898,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { homePath: "", customModels: [], launchArgs: "", + autoCompactWindow: "", }); assert.deepEqual(next.providers.opencode, { // OpenCode is disabled by default; this update only touches paths. @@ -633,7 +951,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { }).pipe(Effect.provide(makeServerSettingsLayer())), ); - it.effect("writes only non-default server settings to disk", () => + it.effect("writes non-default settings and explicit optional provider defaults to disk", () => Effect.gen(function* () { const serverSettings = yield* ServerSettingsModule.ServerSettingsService; const serverConfig = yield* ServerConfig.ServerConfig; @@ -670,7 +988,14 @@ it.layer(NodeServices.layer)("server settings", (it) => { codex: { binaryPath: "/opt/homebrew/bin/codex", }, + cursor: { + enabled: false, + }, + grok: { + enabled: false, + }, opencode: { + enabled: false, serverUrl: "http://127.0.0.1:4096", serverPassword: "secret-password", }, diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index 1bf37335271b..5a8650b7e405 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -42,6 +42,7 @@ import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; import { writeFileStringAtomically } from "./atomicWrite.ts"; import * as ServerConfig from "./config.ts"; import { type DeepPartial, deepMerge } from "@t3tools/shared/Struct"; @@ -230,6 +231,66 @@ export const layerTest = (overrides: DeepPartial = {}) => const ServerSettingsJson = fromLenientJson(ServerSettings); const decodeServerSettingsJsonExit = Schema.decodeUnknownExit(ServerSettingsJson); +const PersistedOptionalProviderSettings = Schema.Struct({ + providers: Schema.optionalKey( + Schema.Struct({ + cursor: Schema.optionalKey(Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean) })), + grok: Schema.optionalKey(Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean) })), + opencode: Schema.optionalKey(Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean) })), + }), + ), +}); +const decodePersistedOptionalProviderSettingsJsonExit = Schema.decodeUnknownExit( + fromLenientJson(PersistedOptionalProviderSettings), +); + +function restoreUsedProviders( + settings: ServerSettings, + persisted: typeof PersistedOptionalProviderSettings.Type, + providerHistory: ReadonlyArray<{ + readonly providerName: string; + readonly providerInstanceId: string | null; + }>, +): ServerSettings { + const usedProviders = new Set(providerHistory.map(({ providerName }) => providerName)); + const usedProviderInstances = new Set( + providerHistory.map( + ({ providerName, providerInstanceId }) => providerInstanceId ?? providerName, + ), + ); + const providerInstances = Object.fromEntries( + Object.entries(settings.providerInstances).map(([instanceId, instance]) => [ + instanceId, + instance.enabled === undefined && + (instance.driver === "cursor" || + instance.driver === "grok" || + instance.driver === "opencode") && + usedProviderInstances.has(instanceId) + ? { ...instance, enabled: true } + : instance, + ]), + ); + + return { + ...settings, + providers: { + ...settings.providers, + cursor: { + ...settings.providers.cursor, + enabled: persisted.providers?.cursor?.enabled ?? usedProviders.has("cursor"), + }, + grok: { + ...settings.providers.grok, + enabled: persisted.providers?.grok?.enabled ?? usedProviders.has("grok"), + }, + opencode: { + ...settings.providers.opencode, + enabled: persisted.providers?.opencode?.enabled ?? usedProviders.has("opencode"), + }, + }, + providerInstances, + }; +} function resolveTextGenerationProvider(settings: ServerSettings): ServerSettings { return isModelSelectionProviderEnabled(settings, settings.textGenerationModelSelection) @@ -265,6 +326,17 @@ const ATOMIC_SETTINGS_KEYS: ReadonlySet = new Set([ "textGenerationModelSelection", ]); +// Preserve both enabled states because provider history cannot recover a new opt-in. +const PERSISTED_SERVER_SETTINGS_DEFAULTS = { + ...DEFAULT_SERVER_SETTINGS, + providers: { + ...DEFAULT_SERVER_SETTINGS.providers, + cursor: { ...DEFAULT_SERVER_SETTINGS.providers.cursor, enabled: undefined }, + grok: { ...DEFAULT_SERVER_SETTINGS.providers.grok, enabled: undefined }, + opencode: { ...DEFAULT_SERVER_SETTINGS.providers.opencode, enabled: undefined }, + }, +}; + function stripDefaultServerSettings(current: unknown, defaults: unknown): unknown | undefined { if (Array.isArray(current) || Array.isArray(defaults)) { return Equal.equals(current, defaults) ? undefined : current; @@ -304,6 +376,7 @@ const make = Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const pathService = yield* Path.Path; const secretStore = yield* ServerSecretStore.ServerSecretStore; + const sql = yield* SqlClient.SqlClient; const writeSemaphore = yield* Semaphore.make(1); const cacheKey = "settings" as const; const changesPubSub = yield* PubSub.unbounded(); @@ -338,21 +411,59 @@ const make = Effect.gen(function* () { ); const loadSettingsFromDisk = Effect.gen(function* () { - if (!(yield* readConfigExists)) { - return DEFAULT_SERVER_SETTINGS; + let settings = DEFAULT_SERVER_SETTINGS; + let persisted: typeof PersistedOptionalProviderSettings.Type = {}; + + if (yield* readConfigExists) { + const raw = yield* readRawConfig; + const decoded = decodeServerSettingsJsonExit(raw); + const persistedSettings = decodePersistedOptionalProviderSettingsJsonExit(raw); + if (persistedSettings._tag === "Success") { + persisted = persistedSettings.value; + } + if (decoded._tag === "Failure" || persistedSettings._tag === "Failure") { + const failure = decoded._tag === "Failure" ? decoded : persistedSettings; + if (failure._tag === "Failure") { + yield* Effect.logWarning("failed to parse settings.json, using defaults", { + path: settingsPath, + issues: Cause.pretty(failure.cause), + cause: failure.cause, + }); + } + } else { + settings = decoded.value; + } } - const raw = yield* readRawConfig; - const decoded = decodeServerSettingsJsonExit(raw); - if (decoded._tag === "Failure") { - yield* Effect.logWarning("failed to parse settings.json, using defaults", { - path: settingsPath, - issues: Cause.pretty(decoded.cause), - cause: decoded.cause, - }); - return DEFAULT_SERVER_SETTINGS; - } - return foldProviderInstanceEnabledFlags(decoded.value); + const providerHistory = yield* sql<{ + readonly providerName: string; + readonly providerInstanceId: string | null; + }>` + SELECT DISTINCT + provider_name AS "providerName", + provider_instance_id AS "providerInstanceId" + FROM projection_thread_sessions + WHERE provider_name IN ('cursor', 'grok', 'opencode') + UNION + SELECT DISTINCT + provider_name AS "providerName", + provider_instance_id AS "providerInstanceId" + FROM provider_session_runtime + WHERE provider_name IN ('cursor', 'grok', 'opencode') + `.pipe( + Effect.mapError( + (cause) => + new ServerSettingsError({ + settingsPath, + operation: "read-provider-history", + cause, + }), + ), + ); + + return foldProviderInstanceEnabledFlags( + restoreUsedProviders(settings, persisted, providerHistory), + ); }); const settingsCache = yield* Cache.make({ @@ -528,7 +639,7 @@ const make = Effect.gen(function* () { const writeSettingsAtomically = Effect.fnUntraced( function* (settings: ServerSettings) { const sparseSettingsJson = yield* encodeServerSettingsJson( - stripDefaultServerSettings(settings, DEFAULT_SERVER_SETTINGS) ?? {}, + stripDefaultServerSettings(settings, PERSISTED_SERVER_SETTINGS_DEFAULTS) ?? {}, ); return yield* writeFileStringAtomically({ diff --git a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts index 21db25e79912..cacdd1a3cd97 100644 --- a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts @@ -115,20 +115,3 @@ it.effect("creates Azure DevOps PRs through provider-neutral input names", () => }); }), ); - -it.effect("uses Azure CLI repository detection for default branch lookup", () => - Effect.gen(function* () { - let cwdInput: string | null = null; - const provider = yield* makeProvider({ - getDefaultBranch: (input) => { - cwdInput = input.cwd; - return Effect.succeed("main"); - }, - }); - - const defaultBranch = yield* provider.getDefaultBranch({ cwd: "/repo" }); - - assert.strictEqual(defaultBranch, "main"); - assert.strictEqual(cwdInput, "/repo"); - }), -); diff --git a/apps/server/src/sourceControl/BitbucketSourceControlProvider.test.ts b/apps/server/src/sourceControl/BitbucketSourceControlProvider.test.ts index eeb4c8fbdd2a..52a15547c573 100644 --- a/apps/server/src/sourceControl/BitbucketSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/BitbucketSourceControlProvider.test.ts @@ -149,20 +149,3 @@ it.effect("creates Bitbucket PRs through provider-neutral input names", () => }); }), ); - -it.effect("uses Bitbucket API repository detection for default branch lookup", () => - Effect.gen(function* () { - let cwdInput: string | null = null; - const provider = yield* makeProvider({ - getDefaultBranch: (input) => { - cwdInput = input.cwd; - return Effect.succeed("main"); - }, - }); - - const defaultBranch = yield* provider.getDefaultBranch({ cwd: "/repo" }); - - assert.strictEqual(defaultBranch, "main"); - assert.strictEqual(cwdInput, "/repo"); - }), -); diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.ts index 1b46369e25c4..b38fe3d5c302 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.ts @@ -1,4 +1,3 @@ -import * as NodeOS from "node:os"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -20,6 +19,7 @@ import { } from "@t3tools/contracts"; import { ServerConfig } from "../config.ts"; +import { expandHomePathWith } from "../pathExpansion.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; const isSourceControlRepositoryError = Schema.is(SourceControlRepositoryError); @@ -77,16 +77,6 @@ function selectRemoteUrl( } } -function expandHomePath(input: string, path: Path.Path): string { - if (input === "~") { - return NodeOS.homedir(); - } - if (input.startsWith("~/") || input.startsWith("~\\")) { - return path.join(NodeOS.homedir(), input.slice(2)); - } - return input; -} - export const make = Effect.gen(function* () { const config = yield* ServerConfig; const fileSystem = yield* FileSystem.FileSystem; @@ -137,7 +127,7 @@ export const make = Effect.gen(function* () { }); } - return path.resolve(expandHomePath(trimmed, path)); + return path.resolve(expandHomePathWith(trimmed, path)); }, ); diff --git a/apps/server/src/telemetry/AnalyticsService.test.ts b/apps/server/src/telemetry/AnalyticsService.test.ts index afdd06f4ea92..a8a77269c266 100644 --- a/apps/server/src/telemetry/AnalyticsService.test.ts +++ b/apps/server/src/telemetry/AnalyticsService.test.ts @@ -7,6 +7,7 @@ import * as Layer from "effect/Layer"; import * as HttpServer from "effect/unstable/http/HttpServer"; import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; +import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as ServerConfig from "../config.ts"; import { getTelemetryIdentifier } from "./Identify.ts"; @@ -20,6 +21,11 @@ interface RecordedBatchRequest { readonly properties?: { readonly index?: number; readonly clientType?: string; + readonly serverOs?: string; + readonly serverArch?: string; + readonly serverAppVersion?: string; + readonly serverMode?: string; + readonly t3CodeVersion?: string; }; }>; } | null; @@ -31,6 +37,11 @@ interface RecordedBatchBody { readonly properties?: { readonly index?: number; readonly clientType?: string; + readonly serverOs?: string; + readonly serverArch?: string; + readonly serverAppVersion?: string; + readonly serverMode?: string; + readonly t3CodeVersion?: string; }; }>; } @@ -71,6 +82,12 @@ it.layer(NodeServices.layer)("AnalyticsService test", (it) => { ); const runtimeLayer = telemetryLayer.pipe( Layer.provide(configLayer), + Layer.provide( + Layer.mergeAll( + Layer.succeed(HostProcessPlatform, "linux"), + Layer.succeed(HostProcessArchitecture, "arm64"), + ), + ), Layer.provideMerge(NodeHttpServer.layerTest), ); @@ -117,6 +134,18 @@ it.layer(NodeServices.layer)("AnalyticsService test", (it) => { ), true, ); + assert.equal( + batchRequests.every((request) => + request.body.batch.every( + (event) => + event.properties?.serverOs === "Linux" && + event.properties.serverArch === "arm64" && + event.properties.serverAppVersion === event.properties.t3CodeVersion && + event.properties.serverMode === "web", + ), + ), + true, + ); }), ); }); diff --git a/apps/server/src/telemetry/AnalyticsService.ts b/apps/server/src/telemetry/AnalyticsService.ts index 5fdc7bdeb199..423ba8d86f7d 100644 --- a/apps/server/src/telemetry/AnalyticsService.ts +++ b/apps/server/src/telemetry/AnalyticsService.ts @@ -7,6 +7,7 @@ * @module AnalyticsService */ import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import type { ClientOs } from "@t3tools/contracts"; import * as Config from "effect/Config"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; @@ -66,6 +67,21 @@ export class AnalyticsService extends Context.Service< ); } +export function serverOsFromNodePlatform(platform: string): ClientOs { + switch (platform) { + case "darwin": + return "macOS"; + case "win32": + return "Windows"; + case "linux": + return "Linux"; + case "android": + return "Android"; + default: + return "other"; + } +} + export const make = Effect.gen(function* () { const telemetryConfig = yield* TelemetryEnvConfig; const httpClient = yield* HttpClient.HttpClient; @@ -121,6 +137,11 @@ export const make = Effect.gen(function* () { arch: hostArchitecture, t3CodeVersion: packageJson.version, clientType, + serverOs: serverOsFromNodePlatform(hostPlatform), + serverArch: hostArchitecture, + serverWslDistro: Option.getOrUndefined(telemetryConfig.wslDistroName), + serverAppVersion: packageJson.version, + serverMode: serverConfig.mode, }, timestamp: event.capturedAt, })), diff --git a/apps/server/src/telemetry/Services/AnalyticsService.ts b/apps/server/src/telemetry/Services/AnalyticsService.ts deleted file mode 100644 index 879a1de7cdbd..000000000000 --- a/apps/server/src/telemetry/Services/AnalyticsService.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Compatibility shim for the intentionally excluded orchestration harness. -export { AnalyticsService } from "../AnalyticsService.ts"; diff --git a/apps/server/src/terminal/PtyAdapter.test.ts b/apps/server/src/terminal/PtyAdapter.test.ts deleted file mode 100644 index f4ac9516537d..000000000000 --- a/apps/server/src/terminal/PtyAdapter.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { assert, describe, it } from "@effect/vitest"; -import * as Schema from "effect/Schema"; - -import * as PtyAdapter from "./PtyAdapter.ts"; - -const isPtySpawnError = Schema.is(PtyAdapter.PtySpawnError); - -describe("PtySpawnError", () => { - it("derives messages from structural context while preserving the full cause chain", () => { - const spawnCause = new Error("spawn /bin/zsh ENOENT"); - const adapterError = new PtyAdapter.PtySpawnError({ - adapter: "node-pty", - shell: "/bin/zsh", - cause: spawnCause, - }); - const managerError = new PtyAdapter.PtySpawnError({ - adapter: "terminal-manager", - attemptedShells: ["/bin/zsh -o nopromptsp", "/bin/bash"], - cause: adapterError, - }); - - assert(isPtySpawnError(managerError)); - assert.strictEqual( - managerError.message, - "Failed to spawn PTY process with terminal-manager. Tried shells: /bin/zsh -o nopromptsp, /bin/bash.", - ); - assert.strictEqual( - adapterError.message, - "Failed to spawn PTY process '/bin/zsh' with node-pty.", - ); - assert.strictEqual(managerError.cause, adapterError); - assert.strictEqual(adapterError.cause, spawnCause); - }); -}); diff --git a/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts b/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts index d1bd68cb19d4..8dcaa3720295 100644 --- a/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts +++ b/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts @@ -11,6 +11,13 @@ import * as Schema from "effect/Schema"; import { expect } from "vite-plus/test"; import * as ServerConfig from "../config.ts"; +import { + SYNTHETIC_CLAUDE_CAPABLE_MODEL, + SYNTHETIC_CLAUDE_COLLIDING_ALIAS, + SYNTHETIC_CLAUDE_MODEL_CATALOG, + SYNTHETIC_CLAUDE_STANDARD_MODEL, + SYNTHETIC_CLAUDE_THINKING_MODEL, +} from "../provider/ClaudeModelCatalog.testFixtures.ts"; import * as TextGeneration from "./TextGeneration.ts"; import { sanitizeThreadTitle } from "./TextGenerationUtils.ts"; import { makeClaudeTextGeneration } from "./ClaudeTextGeneration.ts"; @@ -219,13 +226,17 @@ function withFakeClaudeEnv( ); const config = decodeClaudeSettings(input.claudeConfig ?? {}); - const textGeneration = yield* makeClaudeTextGeneration(config); + const textGeneration = yield* makeClaudeTextGeneration( + config, + undefined, + Effect.succeed(SYNTHETIC_CLAUDE_MODEL_CATALOG), + ); return yield* effectFn(textGeneration); }).pipe(Effect.scoped); } it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGeneration", (it) => { - it.effect("forwards Claude thinking settings for Haiku without passing effort", () => + it.effect("forwards Claude thinking settings without passing unsupported effort", () => withFakeClaudeEnv( { output: JSON.stringify({ @@ -245,10 +256,14 @@ it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGeneration", (it) => { stagedSummary: "M README.md", stagedPatch: "diff --git a/README.md b/README.md", modelSelection: { - ...createModelSelection(ProviderInstanceId.make("claudeAgent"), "claude-haiku-4-5", [ - { id: "thinking", value: false }, - { id: "effort", value: "high" }, - ]), + ...createModelSelection( + ProviderInstanceId.make("claudeAgent"), + SYNTHETIC_CLAUDE_THINKING_MODEL, + [ + { id: "thinking", value: false }, + { id: "effort", value: "high" }, + ], + ), }, }); @@ -257,39 +272,83 @@ it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGeneration", (it) => { ), ); - it.effect("forwards Claude fast mode and supported effort", () => + it.effect("keeps a configured custom alias opaque to the Claude CLI", () => withFakeClaudeEnv( { output: JSON.stringify({ structured_output: { - title: "Improve orchestration flow", - body: "Body", + title: "Keep custom model", + body: "", }, }), - argsMustContain: '--effort max --settings {"fastMode":true}', + argsMustContain: `--model ${SYNTHETIC_CLAUDE_COLLIDING_ALIAS} --dangerously-skip-permissions`, + claudeConfig: { customModels: [SYNTHETIC_CLAUDE_COLLIDING_ALIAS] }, }, (textGeneration) => Effect.gen(function* () { const generated = yield* textGeneration.generatePrContent({ cwd: process.cwd(), baseBranch: "main", - headBranch: "feature/claude-effect", - commitSummary: "Improve orchestration", + headBranch: "feature/custom-model", + commitSummary: "Keep custom model", diffSummary: "1 file changed", diffPatch: "diff --git a/README.md b/README.md", - modelSelection: { - ...createModelSelection(ProviderInstanceId.make("claudeAgent"), "claude-opus-4-6", [ + modelSelection: createModelSelection( + ProviderInstanceId.make("claudeAgent"), + SYNTHETIC_CLAUDE_COLLIDING_ALIAS, + [ { id: "effort", value: "max" }, { id: "fastMode", value: true }, - ]), - }, + { id: "contextWindow", value: "expanded" }, + ], + ), }); - expect(generated.title).toBe("Improve orchestration flow"); + expect(generated.title).toBe("Keep custom model"); }), ), ); + it.effect( + "keeps canonical built-in capabilities when a custom model collides with its alias", + () => + withFakeClaudeEnv( + { + output: JSON.stringify({ + structured_output: { + title: "Improve orchestration flow", + body: "Body", + }, + }), + argsMustContain: `--model ${SYNTHETIC_CLAUDE_CAPABLE_MODEL}[expanded] --effort max --settings {"fastMode":true} --dangerously-skip-permissions`, + claudeConfig: { customModels: [SYNTHETIC_CLAUDE_COLLIDING_ALIAS] }, + }, + (textGeneration) => + Effect.gen(function* () { + const generated = yield* textGeneration.generatePrContent({ + cwd: process.cwd(), + baseBranch: "main", + headBranch: "feature/claude-effect", + commitSummary: "Improve orchestration", + diffSummary: "1 file changed", + diffPatch: "diff --git a/README.md b/README.md", + modelSelection: { + ...createModelSelection( + ProviderInstanceId.make("claudeAgent"), + SYNTHETIC_CLAUDE_CAPABLE_MODEL, + [ + { id: "effort", value: "max" }, + { id: "fastMode", value: true }, + ], + ), + }, + }); + + expect(generated.title).toBe("Improve orchestration flow"); + }), + ), + ); + it.effect("generates thread titles through the Claude provider", () => withFakeClaudeEnv( { @@ -308,7 +367,7 @@ it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGeneration", (it) => { message: "Please investigate reconnect failures after restarting the session.", modelSelection: { instanceId: ProviderInstanceId.make("claudeAgent"), - model: "claude-sonnet-4-6", + model: SYNTHETIC_CLAUDE_STANDARD_MODEL, }, }); @@ -343,7 +402,7 @@ it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGeneration", (it) => { message: "thread title", modelSelection: { instanceId: ProviderInstanceId.make("claudeAgent"), - model: "claude-sonnet-4-6", + model: SYNTHETIC_CLAUDE_STANDARD_MODEL, }, }); @@ -369,7 +428,7 @@ it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGeneration", (it) => { message: "Name this thread.", modelSelection: { instanceId: ProviderInstanceId.make("claudeAgent"), - model: "claude-sonnet-4-6", + model: SYNTHETIC_CLAUDE_STANDARD_MODEL, }, }); diff --git a/apps/server/src/textGeneration/ClaudeTextGeneration.ts b/apps/server/src/textGeneration/ClaudeTextGeneration.ts index aa3e59e2bf21..e5b13b70f1b7 100644 --- a/apps/server/src/textGeneration/ClaudeTextGeneration.ts +++ b/apps/server/src/textGeneration/ClaudeTextGeneration.ts @@ -37,12 +37,16 @@ import { getProviderOptionDescriptors, } from "@t3tools/shared/model"; import { - getClaudeModelCapabilities, - isClaudeUltracodeEffort, - normalizeClaudeCliEffort, - resolveClaudeApiModelId, - resolveClaudeEffort, -} from "../provider/Layers/ClaudeProvider.ts"; + BUNDLED_CLAUDE_MODEL_CATALOG, + type ClaudeModelCatalog, + getClaudeCatalogModelCapabilities, + isClaudeCatalogUltracodeEffort, + normalizeClaudeCatalogEffort, + resolveClaudeCatalogApiModelId, + resolveClaudeCatalogEffort, + resolveClaudeModelSlug, + scopeClaudeModelCatalog, +} from "../provider/ClaudeModelCatalog.ts"; import { makeClaudeEnvironment } from "../provider/Drivers/ClaudeHome.ts"; const CLAUDE_TIMEOUT_MS = 180_000; @@ -61,9 +65,13 @@ const decodeClaudeOutputEnvelope = Schema.decodeEffect(Schema.fromJsonString(Cla export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(function* ( claudeSettings: ClaudeSettings, environment?: NodeJS.ProcessEnv, + modelCatalog: Effect.Effect = Effect.succeed(BUNDLED_CLAUDE_MODEL_CATALOG), ) { const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, environment); + const scopedModelCatalog = modelCatalog.pipe( + Effect.map((catalog) => scopeClaudeModelCatalog(catalog, claudeSettings.customModels)), + ); const readStreamAsString = ( operation: string, @@ -121,21 +129,34 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu outputSchemaJson: S; modelSelection: ModelSelection; }): Effect.fn.Return { + const catalog = yield* scopedModelCatalog; + const resolvedModelSelection = { + ...modelSelection, + model: resolveClaudeModelSlug(catalog, modelSelection.model), + }; const jsonSchemaStr = yield* encodeJsonForOperation( operation, toJsonSchemaObject(outputSchemaJson), "Failed to encode structured output schema.", ); - const caps = getClaudeModelCapabilities(modelSelection.model); + const caps = getClaudeCatalogModelCapabilities(catalog, resolvedModelSelection.model); const descriptors = getProviderOptionDescriptors({ caps, - selections: modelSelection.options, + selections: resolvedModelSelection.options, }); const findDescriptor = (id: string) => descriptors.find((descriptor) => descriptor.id === id); - const rawEffortSelection = getModelSelectionStringOptionValue(modelSelection, "effort"); - const resolvedEffort = resolveClaudeEffort(caps, rawEffortSelection); - const cliEffort = normalizeClaudeCliEffort(resolvedEffort, modelSelection.model); - const ultracode = isClaudeUltracodeEffort(resolvedEffort); + const rawEffortSelection = getModelSelectionStringOptionValue(resolvedModelSelection, "effort"); + const resolvedEffort = resolveClaudeCatalogEffort( + catalog, + resolvedModelSelection.model, + rawEffortSelection, + ); + const cliEffort = normalizeClaudeCatalogEffort( + catalog, + resolvedEffort, + resolvedModelSelection.model, + ); + const ultracode = isClaudeCatalogUltracodeEffort(resolvedEffort); const thinkingDescriptor = findDescriptor("thinking"); const fastModeDescriptor = findDescriptor("fastMode"); const thinking = @@ -166,7 +187,7 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu "--json-schema", jsonSchemaStr, "--model", - resolveClaudeApiModelId(modelSelection), + resolveClaudeCatalogApiModelId(catalog, resolvedModelSelection), ...(cliEffort ? ["--effort", cliEffort] : []), ...(settingsJson ? ["--settings", settingsJson] : []), "--dangerously-skip-permissions", diff --git a/apps/server/src/textGeneration/GrokTextGeneration.ts b/apps/server/src/textGeneration/GrokTextGeneration.ts index 1cf3d13e2252..0b24b260cadc 100644 --- a/apps/server/src/textGeneration/GrokTextGeneration.ts +++ b/apps/server/src/textGeneration/GrokTextGeneration.ts @@ -8,6 +8,7 @@ import type * as EffectAcpErrors from "effect-acp/errors"; import { type GrokSettings, type ModelSelection } from "@t3tools/contracts"; import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; +import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; import { extractJsonObject } from "@t3tools/shared/schemaJson"; import { TextGenerationError } from "@t3tools/contracts"; @@ -26,6 +27,7 @@ import { import { applyGrokAcpModelSelection, currentGrokModelIdFromSessionSetup, + currentGrokReasoningEffortFromSessionSetup, makeGrokAcpRuntime, resolveGrokAcpBaseModelId, } from "../provider/acp/GrokAcpSupport.ts"; @@ -83,10 +85,18 @@ export const makeGrokTextGeneration = Effect.fn("makeGrokTextGeneration")(functi const promptResult = yield* Effect.gen(function* () { const started = yield* runtime.start(); + const requestedReasoningEffort = getModelSelectionStringOptionValue( + modelSelection, + "reasoningEffort", + ); yield* applyGrokAcpModelSelection({ runtime, currentModelId: currentGrokModelIdFromSessionSetup(started.sessionSetupResult), + currentReasoningEffort: currentGrokReasoningEffortFromSessionSetup( + started.sessionSetupResult, + ), requestedModelId: resolvedModel, + requestedReasoningEffort, mapError: (cause) => new TextGenerationError({ operation, diff --git a/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts b/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts index 1fcf9bc4c73a..39c1031727dc 100644 --- a/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts +++ b/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts @@ -11,6 +11,7 @@ import { beforeEach, expect } from "vite-plus/test"; import * as ServerConfig from "../config.ts"; import * as OpenCodeRuntime from "../provider/opencodeRuntime.ts"; +import * as OpenCodeServerOwner from "../provider/OpenCodeServerOwner.ts"; import * as OpenCodeTextGeneration from "./OpenCodeTextGeneration.ts"; import * as TextGeneration from "./TextGeneration.ts"; @@ -18,8 +19,11 @@ const runtimeMock = { state: { startCalls: [] as string[], promptUrls: [] as string[], + promptParts: [] as ReadonlyArray[], authHeaders: [] as Array, closeCalls: [] as string[], + sessionCreateCalls: 0, + connectionError: undefined as Error | undefined, sessionCreateError: undefined as unknown, sessionResult: undefined as { data?: { id: string } } | undefined, promptRequestError: undefined as unknown, @@ -30,8 +34,11 @@ const runtimeMock = { reset() { this.state.startCalls.length = 0; this.state.promptUrls.length = 0; + this.state.promptParts.length = 0; this.state.authHeaders.length = 0; this.state.closeCalls.length = 0; + this.state.sessionCreateCalls = 0; + this.state.connectionError = undefined; this.state.sessionCreateError = undefined; this.state.sessionResult = undefined; this.state.promptRequestError = undefined; @@ -40,7 +47,7 @@ const runtimeMock = { }; const OpenCodeRuntimeTestDouble: OpenCodeRuntime.OpenCodeRuntimeShape = { - startOpenCodeServerProcess: ({ binaryPath }) => + startOpenCodeServerProcess: ({ binaryPath, serverPassword, environment }) => Effect.gen(function* () { const index = runtimeMock.state.startCalls.length + 1; const url = `http://127.0.0.1:${4_300 + index}`; @@ -52,29 +59,51 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntime.OpenCodeRuntimeShape = { runtimeMock.state.closeCalls.push(url); }), ); + const effectiveServerPassword = OpenCodeRuntime.resolveOpenCodeServerPassword({ + external: false, + ...(serverPassword !== undefined ? { serverPassword } : {}), + ...(environment !== undefined ? { environment } : {}), + }); return { url, + ...(effectiveServerPassword !== undefined + ? { serverPassword: effectiveServerPassword } + : {}), + version: "1.14.19", + isRunning: Effect.succeed(true), exitCode: Effect.never, }; }), - connectToOpenCodeServer: ({ serverUrl }) => - Effect.succeed({ - url: serverUrl ?? "http://127.0.0.1:4301", - exitCode: null, - external: Boolean(serverUrl), - }), + connectToOpenCodeServer: ({ serverUrl, serverPassword }) => + runtimeMock.state.connectionError + ? Effect.fail( + new OpenCodeRuntime.OpenCodeRuntimeError({ + operation: "global.health", + detail: runtimeMock.state.connectionError.message, + cause: runtimeMock.state.connectionError, + }), + ) + : Effect.succeed({ + url: serverUrl ?? "http://127.0.0.1:4301", + ...(serverPassword ? { serverPassword } : {}), + version: "1.14.19", + exitCode: null, + external: Boolean(serverUrl), + }), runOpenCodeCommand: () => Effect.succeed({ stdout: "", stderr: "", code: 0 }), createOpenCodeSdkClient: ({ baseUrl, serverPassword }) => ({ session: { create: async () => { + runtimeMock.state.sessionCreateCalls += 1; if (runtimeMock.state.sessionCreateError !== undefined) { throw runtimeMock.state.sessionCreateError; } return runtimeMock.state.sessionResult ?? { data: { id: `${baseUrl}/session` } }; }, - prompt: async () => { + prompt: async (input: { readonly parts: ReadonlyArray }) => { runtimeMock.state.promptUrls.push(baseUrl); + runtimeMock.state.promptParts.push(input.parts); runtimeMock.state.authHeaders.push( serverPassword ? `Basic ${btoa(`opencode:${serverPassword}`)}` : null, ); @@ -160,18 +189,35 @@ const OpenCodeTextGenerationExistingServerTestLayer = Layer.succeed( const DEFAULT_OPENCODE_SETTINGS = Schema.decodeSync(OpenCodeSettings)({ binaryPath: "fake-opencode", }); +const LOCAL_AUTH_OPENCODE_SETTINGS = Schema.decodeSync(OpenCodeSettings)({ + binaryPath: "fake-opencode", + serverPassword: "secret-password", +}); const EXISTING_SERVER_OPENCODE_SETTINGS = Schema.decodeSync(OpenCodeSettings)({ binaryPath: "fake-opencode", serverUrl: "http://127.0.0.1:9999", serverPassword: "secret-password", }); +const EXTERNAL_SERVER_WITHOUT_AUTH_OPENCODE_SETTINGS = Schema.decodeSync(OpenCodeSettings)({ + binaryPath: "fake-opencode", + serverUrl: "http://127.0.0.1:9999", +}); function withOpenCodeTextGeneration( settings: OpenCodeSettings, effectFn: (textGeneration: TextGeneration.TextGeneration["Service"]) => Effect.Effect, + environment?: NodeJS.ProcessEnv, ) { return Effect.gen(function* () { - const textGeneration = yield* OpenCodeTextGeneration.makeOpenCodeTextGeneration(settings); + const serverOwner = yield* OpenCodeServerOwner.make({ + binaryPath: settings.binaryPath, + directory: process.cwd(), + ...(settings.serverPassword ? { serverPassword: settings.serverPassword } : {}), + ...(environment ? { environment } : {}), + }); + const textGeneration = yield* OpenCodeTextGeneration.makeOpenCodeTextGeneration(settings).pipe( + Effect.provideService(OpenCodeServerOwner.OpenCodeServerOwner, serverOwner), + ); return yield* effectFn(textGeneration); }).pipe(Effect.scoped); } @@ -187,6 +233,88 @@ const advanceIdleClock = Effect.gen(function* () { }); it.layer(OpenCodeTextGenerationTestLayer)("OpenCodeTextGeneration", (it) => { + it.effect("excludes generic files from thread title generation", () => + withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => + Effect.gen(function* () { + runtimeMock.state.promptResult = { + data: { + parts: [{ type: "text", text: '{"title":"Review uploaded report"}' }], + }, + }; + + yield* textGeneration.generateThreadTitle({ + cwd: process.cwd(), + message: "Review these attachments.", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + attachments: [ + { + type: "image", + id: "thread-image-attachment", + name: "screenshot.png", + mimeType: "image/png", + sizeBytes: 3, + }, + { + type: "file", + id: "thread-report-attachment-pdf", + name: "report.pdf", + mimeType: "application/pdf", + sizeBytes: 42, + }, + ], + }); + + expect(runtimeMock.state.promptParts[0]).toEqual([ + expect.objectContaining({ type: "text" }), + expect.objectContaining({ type: "file", filename: "screenshot.png" }), + ]); + }), + ), + ); + + it.effect("passes configured authentication to a locally spawned server", () => + withOpenCodeTextGeneration(LOCAL_AUTH_OPENCODE_SETTINGS, (textGeneration) => + Effect.gen(function* () { + yield* textGeneration.generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT); + + expect(runtimeMock.state.startCalls).toEqual(["fake-opencode"]); + expect(runtimeMock.state.authHeaders).toEqual([ + `Basic ${btoa("opencode:secret-password")}`, + ]); + }), + ), + ); + + it.effect("uses an environment-only password for a locally spawned server", () => + withOpenCodeTextGeneration( + DEFAULT_OPENCODE_SETTINGS, + (textGeneration) => + Effect.gen(function* () { + yield* textGeneration.generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT); + + expect(runtimeMock.state.authHeaders).toEqual([ + `Basic ${btoa("opencode:environment-password")}`, + ]); + }), + { OPENCODE_SERVER_PASSWORD: "environment-password" }, + ), + ); + + it.effect("uses settings auth when the local environment password differs", () => + withOpenCodeTextGeneration( + LOCAL_AUTH_OPENCODE_SETTINGS, + (textGeneration) => + Effect.gen(function* () { + yield* textGeneration.generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT); + + expect(runtimeMock.state.authHeaders).toEqual([ + `Basic ${btoa("opencode:secret-password")}`, + ]); + }), + { OPENCODE_SERVER_PASSWORD: "environment-password" }, + ), + ); + it.effect("reuses a warm server across back-to-back requests and closes it after idling", () => withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => Effect.gen(function* () { @@ -418,6 +546,36 @@ it.layer(OpenCodeTextGenerationTestLayer)("OpenCodeTextGeneration", (it) => { it.layer(OpenCodeTextGenerationExistingServerTestLayer)( "OpenCodeTextGeneration with configured server URL", (it) => { + it.effect("does not send a local environment password to a configured server", () => + withOpenCodeTextGeneration( + EXTERNAL_SERVER_WITHOUT_AUTH_OPENCODE_SETTINGS, + (textGeneration) => + Effect.gen(function* () { + yield* textGeneration.generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT); + expect(runtimeMock.state.authHeaders).toEqual([null]); + }), + { OPENCODE_SERVER_PASSWORD: "local-secret" }, + ), + ); + + it.effect("does not create a session when the server version is unsupported", () => + withOpenCodeTextGeneration(EXISTING_SERVER_OPENCODE_SETTINGS, (textGeneration) => + Effect.gen(function* () { + runtimeMock.state.connectionError = new Error( + "OpenCode v1.14.18 is too old. Upgrade to v1.14.19 or newer.", + ); + + const error = yield* textGeneration + .generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT) + .pipe(Effect.flip); + + expect(error).toBeInstanceOf(TextGenerationError); + expect(error.message).toContain("v1.14.18 is too old"); + expect(runtimeMock.state.sessionCreateCalls).toBe(0); + }), + ), + ); + it.effect("reuses a configured OpenCode server URL without spawning or applying idle TTL", () => withOpenCodeTextGeneration(EXISTING_SERVER_OPENCODE_SETTINGS, (textGeneration) => Effect.gen(function* () { diff --git a/apps/server/src/textGeneration/OpenCodeTextGeneration.ts b/apps/server/src/textGeneration/OpenCodeTextGeneration.ts index e09c3db2cffc..e0e960422b18 100644 --- a/apps/server/src/textGeneration/OpenCodeTextGeneration.ts +++ b/apps/server/src/textGeneration/OpenCodeTextGeneration.ts @@ -1,9 +1,5 @@ import * as Effect from "effect/Effect"; -import * as Exit from "effect/Exit"; -import * as Fiber from "effect/Fiber"; import * as Schema from "effect/Schema"; -import * as Scope from "effect/Scope"; -import * as Semaphore from "effect/Semaphore"; import { NonNegativeInt, @@ -31,8 +27,7 @@ import { sanitizeThreadTitle, } from "./TextGenerationUtils.ts"; import * as OpenCodeRuntime from "../provider/opencodeRuntime.ts"; - -const OPENCODE_TEXT_GENERATION_IDLE_TTL = "30 seconds"; +import * as OpenCodeServerOwner from "../provider/OpenCodeServerOwner.ts"; const OpenCodeTextGenerationOperation = Schema.Literals([ "generateCommitMessage", @@ -175,188 +170,12 @@ function getOpenCodeTextResponse(parts: ReadonlyArray | undefined): str .trim(); } -interface SharedOpenCodeTextGenerationServerState { - server: OpenCodeRuntime.OpenCodeServerProcess | null; - /** - * The scope that owns the shared server's lifetime. Closing this scope - * terminates the OpenCode child process and interrupts any fibers the - * runtime forked during startup. We don't hold a `close()` function on - * the server handle anymore — the scope is the only lifecycle handle. - */ - serverScope: Scope.Closeable | null; - binaryPath: string | null; - activeRequests: number; - idleCloseFiber: Fiber.Fiber | null; -} - export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration")(function* ( openCodeSettings: OpenCodeSettings, - environment?: NodeJS.ProcessEnv, ) { const serverConfig = yield* ServerConfig.ServerConfig; const openCodeRuntime = yield* OpenCodeRuntime.OpenCodeRuntime; - const resolvedEnvironment = environment ?? process.env; - const idleFiberScope = yield* Effect.acquireRelease(Scope.make(), (scope) => - Scope.close(scope, Exit.void), - ); - const sharedServerMutex = yield* Semaphore.make(1); - const sharedServerState: SharedOpenCodeTextGenerationServerState = { - server: null, - serverScope: null, - binaryPath: null, - activeRequests: 0, - idleCloseFiber: null, - }; - - const closeSharedServer = Effect.fn("closeSharedServer")(function* () { - const scope = sharedServerState.serverScope; - sharedServerState.server = null; - sharedServerState.serverScope = null; - sharedServerState.binaryPath = null; - if (scope !== null) { - yield* Scope.close(scope, Exit.void).pipe(Effect.ignore); - } - }); - - const cancelIdleCloseFiber = Effect.fn("cancelIdleCloseFiber")(function* () { - const idleCloseFiber = sharedServerState.idleCloseFiber; - sharedServerState.idleCloseFiber = null; - if (idleCloseFiber !== null) { - yield* Fiber.interrupt(idleCloseFiber).pipe(Effect.ignore); - } - }); - - const scheduleIdleClose = Effect.fn("scheduleIdleClose")(function* ( - server: OpenCodeRuntime.OpenCodeServerProcess, - ) { - yield* cancelIdleCloseFiber(); - const fiber = yield* Effect.sleep(OPENCODE_TEXT_GENERATION_IDLE_TTL).pipe( - Effect.andThen( - sharedServerMutex.withPermit( - Effect.gen(function* () { - if (sharedServerState.server !== server || sharedServerState.activeRequests > 0) { - return; - } - sharedServerState.idleCloseFiber = null; - yield* closeSharedServer(); - }), - ), - ), - Effect.forkIn(idleFiberScope), - ); - sharedServerState.idleCloseFiber = fiber; - }); - - const acquireSharedServer = (input: { - readonly binaryPath: string; - readonly operation: - | "generateCommitMessage" - | "generatePrContent" - | "generateBranchName" - | "generateThreadTitle"; - }) => - sharedServerMutex.withPermit( - Effect.gen(function* () { - yield* cancelIdleCloseFiber(); - - const existingServer = sharedServerState.server; - if (existingServer !== null) { - if ( - sharedServerState.binaryPath !== input.binaryPath && - sharedServerState.activeRequests === 0 - ) { - yield* closeSharedServer(); - } else { - if (sharedServerState.binaryPath !== input.binaryPath) { - yield* Effect.logWarning( - "OpenCode shared server binary path mismatch: requested " + - input.binaryPath + - " but active server uses " + - sharedServerState.binaryPath + - "; reusing existing server because there are active requests", - ); - } - sharedServerState.activeRequests += 1; - return existingServer; - } - } - - // Create a fresh scope that owns this shared server. The runtime - // will attach its child-process and fiber finalizers to this scope; - // closing it kills the server and interrupts those fibers. - // - // The `Scope.make` / spawn / record-or-close transitions run inside - // `uninterruptibleMask` so an interrupt arriving between any two - // steps can't orphan the scope (and the child process attached to - // it) before we either close it on failure or hand ownership to - // `sharedServerState`. `restore` keeps the actual spawn - // interruptible; an interrupt during the spawn is captured by - // `Effect.exit` and drives us through the failure branch that - // closes the fresh scope. - return yield* Effect.uninterruptibleMask((restore) => - Effect.gen(function* () { - const serverScope = yield* Scope.make(); - const startedExit = yield* Effect.exit( - restore( - openCodeRuntime - .startOpenCodeServerProcess({ - binaryPath: input.binaryPath, - environment: resolvedEnvironment, - }) - .pipe( - Effect.provideService(Scope.Scope, serverScope), - Effect.mapError( - (cause) => - new TextGenerationError({ - operation: input.operation, - detail: OpenCodeRuntime.openCodeRuntimeErrorDetail(cause), - cause, - }), - ), - ), - ), - ); - if (startedExit._tag === "Failure") { - yield* Scope.close(serverScope, Exit.void).pipe(Effect.ignore); - return yield* Effect.failCause(startedExit.cause); - } - - const server = startedExit.value; - sharedServerState.server = server; - sharedServerState.serverScope = serverScope; - sharedServerState.binaryPath = input.binaryPath; - sharedServerState.activeRequests = 1; - return server; - }), - ); - }), - ); - - const releaseSharedServer = (server: OpenCodeRuntime.OpenCodeServerProcess) => - sharedServerMutex.withPermit( - Effect.gen(function* () { - if (sharedServerState.server !== server) { - return; - } - sharedServerState.activeRequests = Math.max(0, sharedServerState.activeRequests - 1); - if (sharedServerState.activeRequests === 0) { - yield* scheduleIdleClose(server); - } - }), - ); - - // Module-level finalizer: on layer shutdown, cancel the idle close fiber - // and close the shared server scope. Consumers therefore cannot leak - // the shared OpenCode server by forgetting to call anything. - yield* Effect.addFinalizer(() => - sharedServerMutex.withPermit( - Effect.gen(function* () { - yield* cancelIdleCloseFiber(); - sharedServerState.activeRequests = 0; - yield* closeSharedServer(); - }), - ), - ); + const serverOwner = yield* OpenCodeServerOwner.OpenCodeServerOwner; const runOpenCodeJson = Effect.fn("runOpenCodeJson")(function* (input: { readonly operation: OpenCodeTextGenerationOperation; @@ -375,19 +194,22 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" } const fileParts = OpenCodeRuntime.toOpenCodeFileParts({ - attachments: input.attachments, + attachments: input.attachments?.filter((attachment) => attachment.type === "image"), resolveAttachmentPath: (attachment) => resolveAttachmentPath({ attachmentsDir: serverConfig.attachmentsDir, attachment }), }); const runAgainstServer = Effect.fn("runOpenCodeJson.runAgainstServer")( - function* (server: Pick) { + function* ( + server: Pick< + OpenCodeRuntime.OpenCodeServerConnection, + "url" | "serverPassword" | "version" + >, + ) { const client = openCodeRuntime.createOpenCodeSdkClient({ baseUrl: server.url, directory: input.cwd, - ...(openCodeSettings.serverUrl.length > 0 && openCodeSettings.serverPassword - ? { serverPassword: openCodeSettings.serverPassword } - : {}), + ...(server.serverPassword !== undefined ? { serverPassword: server.serverPassword } : {}), }); const session = yield* Effect.tryPromise({ try: () => @@ -496,17 +318,31 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" }), ); - const rawOutput = + const serverOutput = openCodeSettings.serverUrl.length > 0 - ? yield* runAgainstServer({ url: openCodeSettings.serverUrl }) - : yield* Effect.acquireUseRelease( - acquireSharedServer({ + ? openCodeRuntime + .connectToOpenCodeServer({ binaryPath: openCodeSettings.binaryPath, + directory: input.cwd, + serverUrl: openCodeSettings.serverUrl, + ...(openCodeSettings.serverPassword + ? { serverPassword: openCodeSettings.serverPassword } + : {}), + }) + .pipe(Effect.flatMap(runAgainstServer), Effect.scoped) + : serverOwner.withServer(runAgainstServer); + const rawOutput = yield* serverOutput.pipe( + Effect.catchTags({ + OpenCodeRuntimeError: (cause) => + Effect.fail( + new TextGenerationError({ operation: input.operation, + detail: OpenCodeRuntime.openCodeRuntimeErrorDetail(cause), + cause, }), - runAgainstServer, - releaseSharedServer, - ); + ), + }), + ); const decodeOutput = Schema.decodeEffect(Schema.fromJsonString(input.outputSchemaJson)); return yield* decodeOutput(extractJsonObject(rawOutput)).pipe( diff --git a/apps/server/src/textGeneration/TextGenerationPresets.ts b/apps/server/src/textGeneration/TextGenerationPresets.ts index 70955742148a..0f5d03480f49 100644 --- a/apps/server/src/textGeneration/TextGenerationPresets.ts +++ b/apps/server/src/textGeneration/TextGenerationPresets.ts @@ -1,4 +1,4 @@ -import type { TextGenerationPolicy, TextGenerationPolicyKind } from "./TextGenerationPolicy.ts"; +import type { TextGenerationPolicy } from "./TextGenerationPolicy.ts"; export const defaultTextGenerationPolicy: TextGenerationPolicy = { kind: "default", @@ -30,12 +30,3 @@ export const customTextGenerationPolicy = ( inferRepositoryConventions: false, ...overrides, }); - -export const textGenerationPresets: Record< - Exclude, - TextGenerationPolicy -> = { - default: defaultTextGenerationPolicy, - conventional_commits: conventionalCommitsTextGenerationPolicy, - repo_conventions: repositoryConventionsTextGenerationPolicy, -}; diff --git a/apps/server/src/textGeneration/TextGenerationPrompts.test.ts b/apps/server/src/textGeneration/TextGenerationPrompts.test.ts index 7614cc9e00f3..f382f9af0921 100644 --- a/apps/server/src/textGeneration/TextGenerationPrompts.test.ts +++ b/apps/server/src/textGeneration/TextGenerationPrompts.test.ts @@ -146,7 +146,7 @@ describe("buildBranchNamePrompt", () => { }); describe("buildThreadTitlePrompt", () => { - it("includes the user message in the prompt", () => { + it("includes the user message and the title guidance rules", () => { const result = buildThreadTitlePrompt({ message: "Investigate reconnect regressions after session restore", }); diff --git a/apps/server/src/textGeneration/TextGenerationPrompts.ts b/apps/server/src/textGeneration/TextGenerationPrompts.ts index 5eaef8c36ce4..c676a1760d0d 100644 --- a/apps/server/src/textGeneration/TextGenerationPrompts.ts +++ b/apps/server/src/textGeneration/TextGenerationPrompts.ts @@ -16,7 +16,7 @@ const EARLIER_CONTENT_TRUNCATION_MARKER = "[Earlier content truncated]\n\n"; function policyInstruction(instruction: string | undefined): ReadonlyArray { const trimmed = instruction?.trim(); - return trimmed ? ["", "Additional instructions:", limitSection(trimmed, 4_000)] : []; + return trimmed ? ["", "Additional instructions:", limitSection(trimmed, 20_000)] : []; } // --------------------------------------------------------------------------- diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts new file mode 100644 index 000000000000..8fc86ee3d462 --- /dev/null +++ b/apps/server/src/usage/UsageService.test.ts @@ -0,0 +1,226 @@ +// @effect-diagnostics nodeBuiltinImport:off - the suite seeds and grows real +// transcript trees on disk, outside the service's Effect FileSystem. +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import { assert, describe, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; +import { UsageDay, type UsageSummaryInput } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Scheduler from "effect/Scheduler"; +import { HttpClient, HttpClientResponse } from "effect/unstable/http"; + +import * as ServerConfig from "../config.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import * as UsageService from "./UsageService.ts"; + +function claudeLine(id: number, outputTokens: number): string { + return `${JSON.stringify({ + type: "assistant", + timestamp: "2026-08-01T10:00:00Z", + requestId: `req_${id}`, + sessionId: "session-1", + message: { + id: `msg_${id}`, + model: "claude-fable-5", + usage: { input_tokens: 10, output_tokens: outputTokens }, + }, + })}\n`; +} + +const WINDOW: UsageSummaryInput = { + timeZone: "UTC", + sinceDay: UsageDay.make("2026-07-31"), + untilDay: UsageDay.make("2026-08-02"), +}; + +const setup = Effect.gen(function* () { + const home = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "usage-service-test-")), + ); + yield* Effect.addFinalizer(() => + Effect.promise(() => NodeFSP.rm(home, { recursive: true, force: true })), + ); + const transcriptDir = NodePath.join(home, "claude", "projects", "proj"); + yield* Effect.promise(() => NodeFSP.mkdir(transcriptDir, { recursive: true })); + return { + home, + transcript: NodePath.join(transcriptDir, "session.jsonl"), + settings: { + providers: { + claudeAgent: { homePath: NodePath.join(home, "claude") }, + codex: { homePath: NodePath.join(home, "codex") }, + }, + }, + }; +}); + +const serviceLayers = (input: { + readonly prefix: string; + readonly home: string; + readonly settings: Parameters[0]; + readonly onRatesFetch?: () => void; +}) => + ServerConfig.layerTest(process.cwd(), { prefix: input.prefix }).pipe( + Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(ServerSettings.layerTest(input.settings)), + Layer.provideMerge( + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.sync(() => { + input.onRatesFetch?.(); + // Unparsable rates: every scan retries the fetch, which makes the + // fetch count a boundary-level observation of how many scans ran. + return HttpClientResponse.fromWeb(request, Response.json({})); + }), + ), + ), + ), + Layer.provideMerge( + Layer.succeed(HostProcessEnvironment, { GROK_HOME: NodePath.join(input.home, "grok") }), + ), + ); + +function totalOutputTokens(summary: { buckets: readonly { totals: { outputTokens: number } }[] }) { + return summary.buckets.reduce((sum, bucket) => sum + bucket.totals.outputTokens, 0); +} + +describe("UsageService", () => { + it.live("counts appended usage on a rescan of a grown transcript", () => + Effect.gen(function* () { + const { transcript, settings, home } = yield* setup; + yield* Effect.promise(() => NodeFSP.writeFile(transcript, claudeLine(1, 5))); + + const service = yield* UsageService.make.pipe( + Effect.provide(serviceLayers({ prefix: "usage-service-grow-test", home, settings })), + ); + + const first = yield* service.readSummary(WINDOW); + assert.strictEqual(totalOutputTokens(first), 5); + + yield* Effect.promise(() => NodeFSP.appendFile(transcript, claudeLine(2, 7))); + const second = yield* service.readSummary(WINDOW); + assert.strictEqual(totalOutputTokens(second), 12); + }).pipe(Effect.scoped), + ); + + it.live("shares one scan between concurrent identical requests", () => + Effect.gen(function* () { + const { transcript, settings, home } = yield* setup; + yield* Effect.promise(() => NodeFSP.writeFile(transcript, claudeLine(1, 5))); + + let ratesFetches = 0; + const service = yield* UsageService.make.pipe( + Effect.provide( + serviceLayers({ + prefix: "usage-service-flight-test", + home, + settings, + onRatesFetch: () => { + ratesFetches += 1; + }, + }), + ), + ); + + const [first, second] = yield* Effect.all( + [service.readSummary(WINDOW), service.readSummary(WINDOW)], + { concurrency: 2 }, + ); + assert.deepStrictEqual(first, second); + assert.strictEqual(ratesFetches, 1); + + // A later request is fresh work again, not a stale cached answer. + yield* service.readSummary(WINDOW); + assert.strictEqual(ratesFetches, 2); + }).pipe(Effect.scoped), + ); + + it.live("does not orphan an in-flight scan when its first caller is interrupted", () => + Effect.gen(function* () { + const { settings, home } = yield* setup; + const service = yield* UsageService.make.pipe( + Effect.provide( + serviceLayers({ prefix: "usage-service-interruption-test", home, settings }), + ), + ); + + let orphanedAt: number | undefined; + for (let interruptAt = 1; interruptAt <= 31; interruptAt += 1) { + const tasks: Array<() => void> = []; + const dispatcher: Scheduler.SchedulerDispatcher = { + scheduleTask: (task) => tasks.push(task), + flush: () => { + let task: (() => void) | undefined; + while ((task = tasks.shift()) !== undefined) task(); + }, + }; + + let requestFiber: Fiber.Fiber | undefined; + let requestChecks = 0; + const scheduler: Scheduler.Scheduler = { + executionMode: "async", + makeDispatcher: () => dispatcher, + shouldYield: (fiber) => { + if (fiber !== requestFiber) return false; + requestChecks += 1; + if (requestChecks !== interruptAt) return false; + fiber.interruptUnsafe(); + return true; + }, + }; + + // Each candidate needs a distinct key because the broken case leaves + // its entry in the service's private in-flight map. The invalid window + // keeps the real scan synchronous once its detached fiber starts. + const input: UsageSummaryInput = { + ...WINDOW, + sinceDay: UsageDay.make("2026-09-01"), + untilDay: UsageDay.make(`2026-08-${String(interruptAt).padStart(2, "0")}`), + }; + const first = yield* service + .readSummary(input) + .pipe( + Effect.exit, + Effect.provideService(Scheduler.Scheduler, scheduler), + Effect.forkChild, + ); + requestFiber = first; + yield* Effect.yieldNow; + dispatcher.flush(); + + const second = yield* service.readSummary(input).pipe( + Effect.match({ + onFailure: (error) => error.reason, + onSuccess: () => "success" as const, + }), + Effect.provideService(Scheduler.Scheduler, scheduler), + Effect.forkChild, + ); + yield* Effect.yieldNow; + dispatcher.flush(); + const secondExit = second.pollUnsafe(); + if (secondExit === undefined) { + second.interruptUnsafe(); + orphanedAt = interruptAt; + break; + } + if (Exit.isFailure(secondExit)) { + assert.fail("the matching request fiber was interrupted"); + } + assert.strictEqual(secondExit.value, "invalidWindow"); + } + + assert.isUndefined( + orphanedAt, + `interruption left the next matching request pending at scheduler check ${orphanedAt}`, + ); + }).pipe(Effect.scoped), + ); +}); diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index 0bf131ac973b..16a7478d954e 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -1,13 +1,14 @@ /** * UsageService - scans provider transcripts and returns priced usage buckets. * - * The scan reads the provider CLIs' own session files rather than T3 Code's - * orchestration projections, so usage covers turns driven outside T3 Code too. - * This is the approach `ccusage` takes. + * The scan reads the provider CLIs' own session files (Claude Code, Codex, and + * Grok Build) rather than T3 Code's orchestration projections, so usage covers + * turns driven outside T3 Code too. This is the approach `ccusage` takes. * * Transcripts are append-only, so parsed records are memoised per file by * `(size, mtime)`. A cold 30-day scan of ~1.4 GB lands around 2-3 seconds; warm - * scans only reparse files that changed. + * scans only reparse files that changed, and a file that merely grew resumes + * from its cached parse position so only the appended bytes are read. * * @module UsageService */ @@ -21,10 +22,12 @@ import { type UsageSummaryInput, UsageReadError, } from "@t3tools/contracts"; +import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; import * as Cause from "effect/Cause"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -34,6 +37,7 @@ import * as Schema from "effect/Schema"; import { HttpClient, HttpClientResponse } from "effect/unstable/http"; import { ServerConfig } from "../config.ts"; +import { expandHomePath } from "../pathExpansion.ts"; import * as ServerSettings from "../serverSettings.ts"; import { resolveClaudeHomePath } from "../provider/Drivers/ClaudeHome.ts"; import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts"; @@ -123,6 +127,7 @@ export const make = Effect.gen(function* () { const config = yield* ServerConfig; const settingsService = yield* ServerSettings.ServerSettingsService; const httpClient = yield* HttpClient.HttpClient; + const hostEnvironment = yield* HostProcessEnvironment; const fileCache: ScanCache = new Map(); let cacheDirty = false; @@ -218,10 +223,22 @@ export const make = Effect.gen(function* () { const claudeHome = yield* resolveClaudeHomePath(settings.providers.claudeAgent); const claudeDir = yield* resolveClaudeTranscriptDir(claudeHome); const codexLayout = yield* resolveCodexHomeLayout(settings.providers.codex); + // Grok Settings only expose the binary path; home is `$GROK_HOME` or `~/.grok`. + // Empty/whitespace GROK_HOME must fall back: coalescing alone would scan cwd. + const grokHomeEnv = hostEnvironment["GROK_HOME"]?.trim() ?? ""; + const grokHome = + grokHomeEnv.length > 0 + ? path.resolve(expandHomePath(grokHomeEnv)) + : path.join(NodeOS.homedir(), ".grok"); return [ { provider: "claude" as const, dir: claudeDir }, { provider: "codex" as const, dir: path.join(codexLayout.sharedHomePath, "sessions") }, + { + provider: "grok" as const, + dir: path.join(grokHome, "sessions"), + fileName: "updates.jsonl", + }, ]; }); @@ -257,7 +274,14 @@ export const make = Effect.gen(function* () { ); }); - /** Parses one transcript, reusing the cached result when it is unchanged. */ + /** + * Parses one transcript, reusing the cached result when it is unchanged. + * + * A file that only grew re-parses from the cached position, so an actively + * written multi-hundred-megabyte rollout costs its appended bytes per scan + * rather than a full re-read. The reader verifies the position's guard bytes + * and silently restarts from byte 0 when they no longer match. + */ const readFileRecords = ( filePath: string, size: number, @@ -274,23 +298,85 @@ export const make = Effect.gen(function* () { cached.mtimeMs === mtimeMs && cached.provider === provider ) { - return cached.records; + return cached.tailRecords.length === 0 + ? cached.records + : [...cached.records, ...cached.tailRecords]; } - const parsed = yield* Effect.promise(() => readTranscriptRecords(filePath, provider)); + // Only a strictly grown file may resume. Same size with a new mtime, or + // a shrunken file, means rewritten content; re-parse it whole. + const resumeFrom = + cached !== undefined && cached.provider === provider && size > cached.size + ? cached.position + : undefined; + + const parsed = yield* Effect.promise(() => + readTranscriptRecords(filePath, provider, resumeFrom), + ); // A read failure is not an empty transcript: caching it under this // (size, mtime) would silently drop the file's usage until it changes. if (parsed === null) return []; - // Stored already de-duplicated within the file, which is 99% of all - // duplicates. The aggregator still runs the cross-file dedupe pass. - const records = dedupeWithinFile(parsed); - fileCache.set(filePath, { size, mtimeMs, provider, records }); + // Stored already de-duplicated within the file, which is 99% of all + // duplicates. The aggregator still runs the cross-file dedupe pass. One + // seen set spans the cached base, the new lines, and the tail so a + // resumed parse dedupes exactly like a full one. + const base = parsed.resumed && cached !== undefined ? cached.records : []; + const seen = new Set(); + const records = dedupeWithinFile([...base, ...parsed.records], seen); + const tailRecords = dedupeWithinFile(parsed.tailRecords, seen); + + fileCache.set(filePath, { + size, + mtimeMs, + provider, + records, + tailRecords, + position: parsed.position, + }); cacheDirty = true; - return records; + return tailRecords.length === 0 ? records : [...records, ...tailRecords]; }); - const readSummary = Effect.fn("UsageService.readSummary")(function* (input: UsageSummaryInput) { + /** One provider directory's walk and parse, before rates are involved. */ + interface ScannedDir { + readonly provider: UsageProviderKind; + readonly dir: string; + readonly volumeId: string; + /** Parsed records per file, or `null` when the directory does not exist. */ + readonly files: + | readonly { readonly path: string; readonly records: readonly UsageRecord[] }[] + | null; + } + + const collectDirs = Effect.fn("UsageService.collectDirs")(function* (windowStartMs: number) { + // The home resolvers ask for `Path` themselves; satisfy them from the + // instance we already hold so the scan stays context-free. + const dirs = yield* resolveTranscriptDirs().pipe(Effect.provideService(Path.Path, path)); + const scanned: ScannedDir[] = []; + for (const { provider, dir, fileName } of dirs) { + const volumeId = yield* Effect.promise(() => readDirectoryVolumeId(dir)); + const exists = yield* fileSystem + .exists(dir) + .pipe(Effect.catchCause(() => Effect.succeed(false))); + if (!exists) { + scanned.push({ provider, dir, volumeId, files: null }); + continue; + } + const files = yield* Effect.promise(() => + listTranscriptFiles(dir, windowStartMs, fileName === undefined ? undefined : { fileName }), + ); + const parsedFiles: { path: string; records: readonly UsageRecord[] }[] = []; + for (const file of files) { + const records = yield* readFileRecords(file.path, file.size, file.mtimeMs, provider); + parsedFiles.push({ path: file.path, records }); + } + scanned.push({ provider, dir, volumeId, files: parsedFiles }); + } + return scanned; + }); + + const scanSummary = Effect.fn("UsageService.scanSummary")(function* (input: UsageSummaryInput) { if (input.sinceDay > input.untilDay) { return yield* new UsageReadError({ reason: "invalidWindow", @@ -323,13 +409,9 @@ export const make = Effect.gen(function* () { } const startedAtMs = yield* Clock.currentTimeMillis; - yield* ensureRates(); yield* ensureScanCacheLoaded; const hostId = NodeOS.hostname(); - // The home resolvers ask for `Path` themselves; satisfy them from the - // instance we already hold so `readSummary` stays context-free. - const dirs = yield* resolveTranscriptDirs().pipe(Effect.provideService(Path.Path, path)); const windowStart = DateTime.make(`${input.sinceDay}T00:00:00Z`); if (Option.isNone(windowStart)) { return yield* new UsageReadError({ @@ -340,6 +422,13 @@ export const make = Effect.gen(function* () { const windowStartMs = (hourlyWindow?.sinceTimeMs ?? DateTime.toEpochMillis(windowStart.value)) - MTIME_SLACK_MS; + // Pricing only matters once records are aggregated, so the rate table + // loads while transcripts stream instead of gating them: a cold rates + // fetch on a slow network no longer delays the scan by its own timeout. + const [, scannedDirs] = yield* Effect.all([ensureRates(), collectDirs(windowStartMs)], { + concurrency: 2, + }); + const aggregator = new UsageAggregator({ timeZone: input.timeZone, sinceDay: input.sinceDay, @@ -353,13 +442,8 @@ export const make = Effect.gen(function* () { const livePaths = new Set(); const walkedRoots: string[] = []; - for (const { provider, dir } of dirs) { - const volumeId = yield* Effect.promise(() => readDirectoryVolumeId(dir)); - const exists = yield* fileSystem - .exists(dir) - .pipe(Effect.catchCause(() => Effect.succeed(false))); - - if (!exists) { + for (const { provider, dir, volumeId, files } of scannedDirs) { + if (files === null) { sources.push({ fingerprint: { hostId, provider, resolvedHomePath: dir, volumeId }, status: "missing", @@ -373,7 +457,6 @@ export const make = Effect.gen(function* () { } walkedRoots.push(dir); - const files = yield* Effect.promise(() => listTranscriptFiles(dir, windowStartMs)); let scannedFiles = 0; let skippedFiles = 0; // Distinct per directory. Buckets carry per-cell session counts, but a @@ -382,13 +465,12 @@ export const make = Effect.gen(function* () { for (const file of files) { livePaths.add(file.path); - const records = yield* readFileRecords(file.path, file.size, file.mtimeMs, provider); - if (records.length === 0) { + if (file.records.length === 0) { skippedFiles += 1; continue; } scannedFiles += 1; - for (const record of records) { + for (const record of file.records) { // Only sessions that contributed in-window count: the mtime slack // admits boundary files whose records fall outside the range. if (aggregator.add(record) && record.sessionId.length > 0) { @@ -442,6 +524,52 @@ export const make = Effect.gen(function* () { } satisfies UsageSummary; }); + /** + * In-flight scans by window, so concurrent identical requests (the usage + * page open on two clients at once) share one scan instead of racing over + * the same corpus twice. + */ + const inflightScans = new Map>(); + + const scanKey = (input: UsageSummaryInput): string => + JSON.stringify([ + input.timeZone, + input.sinceDay, + input.untilDay, + input.resolution ?? "day", + input.sinceTime ?? null, + input.untilTime ?? null, + ]); + + const readSummary = Effect.fn("UsageService.readSummary")(function* (input: UsageSummaryInput) { + const key = scanKey(input); + const deferred = yield* Effect.uninterruptible( + Effect.gen(function* () { + const existing = inflightScans.get(key); + if (existing !== undefined) return existing; + + // Enrollment and detached-fiber creation must be atomic. Otherwise a + // canceled first caller can leave a Deferred with no scan to finish it. + const created = Deferred.makeUnsafe(); + inflightScans.set(key, created); + // Detached so one departing client cannot tear the scan out from under + // the fibers awaiting it; a finished scan warms the cache either way. + yield* scanSummary(input).pipe( + Effect.onExit((exit) => + Effect.sync(() => inflightScans.delete(key)).pipe( + Effect.andThen(Deferred.done(created, exit)), + ), + ), + Effect.forkDetach, + ); + return created; + }), + ); + // Waiting stays interruptible. The detached scan continues for other + // callers and still warms the cache if this caller leaves. + return yield* Deferred.await(deferred); + }); + return { readSummary } as const; }); diff --git a/apps/server/src/usage/usagePricing.test.ts b/apps/server/src/usage/usagePricing.test.ts new file mode 100644 index 000000000000..2ea27375b148 --- /dev/null +++ b/apps/server/src/usage/usagePricing.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { lookupRate, normalizeModelName, parseRateTable } from "./usagePricing.ts"; + +const rate = (input: number, cacheRead?: number) => ({ + input_cost_per_token: input, + output_cost_per_token: input * 5, + ...(cacheRead === undefined ? {} : { cache_read_input_token_cost: cacheRead }), +}); + +describe("usage pricing", () => { + it("keeps the existing model-name normalization contract", () => { + expect(normalizeModelName(" Anthropic/Claude-Opus-5 ")).toBe("claude-opus-5"); + }); + + it("keeps the canonical Fable rate separate from DeepInfra in either order", () => { + const canonical = ["claude-fable-5", rate(1e-5, 1e-6)] as const; + const deepInfra = ["deepinfra/anthropic/claude-fable-5", rate(1e-5)] as const; + + for (const entries of [ + [canonical, deepInfra], + [deepInfra, canonical], + ]) { + const table = parseRateTable(Object.fromEntries(entries)); + + expect(lookupRate(table, "claude-fable-5")?.cacheReadCostPerToken).toBe(1e-6); + expect(lookupRate(table, "deepinfra/anthropic/claude-fable-5")?.cacheReadCostPerToken).toBe( + 1e-5, + ); + expect(lookupRate(table, "other/claude-fable-5")).toBeNull(); + } + }); + + it("adds a bare alias when every qualified entry has the same rate", () => { + const table = parseRateTable({ + "provider-a/example-model": rate(1), + "provider-b/example-model": rate(1), + }); + + expect(lookupRate(table, "example-model")).toEqual( + lookupRate(table, "provider-a/example-model"), + ); + }); + + it("leaves an ambiguous bare name unpriced", () => { + const table = parseRateTable({ + "provider-a/example-model": rate(1), + "provider-b/example-model": rate(3), + }); + + expect(lookupRate(table, "provider-a/example-model")?.inputCostPerToken).toBe(1); + expect(lookupRate(table, "provider-b/example-model")?.inputCostPerToken).toBe(3); + expect(lookupRate(table, "example-model")).toBeNull(); + }); +}); diff --git a/apps/server/src/usage/usagePricing.ts b/apps/server/src/usage/usagePricing.ts index f0e59a874399..3d7f5fd29485 100644 --- a/apps/server/src/usage/usagePricing.ts +++ b/apps/server/src/usage/usagePricing.ts @@ -44,6 +44,9 @@ function finiteNumber(value: unknown): number | null { * Entries without both an input and an output rate are dropped: a half-priced * model would silently under-report cost, which is worse than reporting the * model as unpriced. + * + * Entries keep their full normalized key; a bare name is aliased only when no + * canonical entry exists and every qualified entry has the same rate. */ export function parseRateTable(document: unknown): RateTable { const table = new Map(); @@ -56,7 +59,9 @@ export function parseRateTable(document: unknown): RateTable { const output = finiteNumber(entry.output_cost_per_token); if (input === null || output === null) continue; - table.set(normalizeModelName(name), { + const key = normalizeRateKey(name); + if (key.length === 0) continue; + table.set(key, { inputCostPerToken: input, outputCostPerToken: output, // Anthropic bills cache reads at a discount and cache writes at a @@ -66,20 +71,52 @@ export function parseRateTable(document: unknown): RateTable { cacheCreationCostPerToken: finiteNumber(entry.cache_creation_input_token_cost) ?? input, }); } + + // `null` marks a bare name claimed at conflicting rates: no alias for it. + const aliasCandidates = new Map(); + for (const [key, rate] of table) { + const alias = bareModelName(key); + if (alias.length === 0 || alias === key || table.has(alias)) continue; + const held = aliasCandidates.get(alias); + if (held === undefined) { + aliasCandidates.set(alias, rate); + } else if (held !== null && !sameRate(held, rate)) { + aliasCandidates.set(alias, null); + } + } + for (const [alias, rate] of aliasCandidates) { + if (rate !== null) table.set(alias, rate); + } + return table; } +function sameRate(a: ModelRate, b: ModelRate): boolean { + return ( + a.inputCostPerToken === b.inputCostPerToken && + a.outputCostPerToken === b.outputCostPerToken && + a.cacheReadCostPerToken === b.cacheReadCostPerToken && + a.cacheCreationCostPerToken === b.cacheCreationCostPerToken + ); +} + +function normalizeRateKey(model: string): string { + return model.trim().toLowerCase(); +} + /** * Canonicalises a model name for lookup. * - * Strips a `provider/` prefix (LiteLLM publishes both `claude-opus-5` and - * `anthropic/claude-opus-5`) and lowercases, since transcripts are inconsistent - * about casing. + * Strips a `provider/` prefix and lowercases, since transcripts are + * inconsistent about casing. */ export function normalizeModelName(model: string): string { - const trimmed = model.trim().toLowerCase(); - const slash = trimmed.lastIndexOf("/"); - return slash === -1 ? trimmed : trimmed.slice(slash + 1); + return bareModelName(normalizeRateKey(model)); +} + +function bareModelName(key: string): string { + const slash = key.lastIndexOf("/"); + return slash === -1 ? key : key.slice(slash + 1); } /** @@ -99,9 +136,10 @@ const UNPRICEABLE_MODELS = new Set([ ]); export function lookupRate(table: RateTable, model: string): ModelRate | null { - const normalized = normalizeModelName(model); - if (normalized.length === 0 || UNPRICEABLE_MODELS.has(normalized)) return null; - return table.get(normalized) ?? null; + const key = normalizeRateKey(model); + const bareName = bareModelName(key); + if (bareName.length === 0 || UNPRICEABLE_MODELS.has(bareName)) return null; + return table.get(key) ?? null; } export interface PricedUsage { diff --git a/apps/server/src/usage/usageScanCache.test.ts b/apps/server/src/usage/usageScanCache.test.ts index 64673e96c090..fdb0aabafa40 100644 --- a/apps/server/src/usage/usageScanCache.test.ts +++ b/apps/server/src/usage/usageScanCache.test.ts @@ -5,6 +5,7 @@ import { dedupeWithinFile, encodeScanCache, pruneScanCache, + type CachedFile, type ScanCache, } from "./usageScanCache.ts"; import type { UsageRecord } from "./usageTranscripts.ts"; @@ -28,10 +29,27 @@ function record(overrides: Partial = {}): UsageRecord { }; } +function position(overrides: Partial = {}): CachedFile["position"] { + return { + resumeOffset: 120, + guardLength: 64, + guardHash: 0xdeadbeef, + codexState: null, + ...overrides, + }; +} + function cacheWith(entries: readonly [string, number, readonly UsageRecord[]][]): ScanCache { const cache: ScanCache = new Map(); for (const [path, mtimeMs, records] of entries) { - cache.set(path, { size: records.length * 10, mtimeMs, provider: "claude", records }); + cache.set(path, { + size: records.length * 10, + mtimeMs, + provider: "claude", + records, + tailRecords: [], + position: position(), + }); } return cache; } @@ -42,12 +60,74 @@ describe("scan cache round trip", () => { ["/a.jsonl", 100, [record(), record({ dedupeKey: "msg_2:", model: "claude-opus-5" })]], ["/b.jsonl", 200, [record({ sessionId: "session-b", reportedCostUsd: 1.5 })]], ]); + original.set("/grok.jsonl", { + size: 40, + mtimeMs: 300, + provider: "grok", + records: [ + record({ provider: "grok", model: "grok-4.5-build", dedupeKey: "s:p:grok-4.5-build" }), + ], + tailRecords: [record({ provider: "grok", model: "grok-4.5-build", dedupeKey: null })], + position: position({ resumeOffset: 30, guardLength: 30, guardHash: 123 }), + }); + original.set("/codex.jsonl", { + size: 80, + mtimeMs: 400, + provider: "codex", + records: [record({ provider: "codex", model: "gpt-5.2-codex", dedupeKey: null })], + tailRecords: [], + position: position({ + codexState: { + model: "gpt-5.2-codex", + sessionId: "session-c", + lastUsageSignature: '{"input_tokens":1}', + sawSessionMeta: true, + suppressingForkCopies: false, + forkCopyAnchorMs: 0, + }, + }), + }); const restored = decodeScanCache(JSON.parse(JSON.stringify(encodeScanCache(original)))); - expect(restored.size).toBe(2); + expect(restored.size).toBe(4); expect(restored.get("/a.jsonl")).toEqual(original.get("/a.jsonl")); expect(restored.get("/b.jsonl")).toEqual(original.get("/b.jsonl")); + expect(restored.get("/grok.jsonl")).toEqual(original.get("/grok.jsonl")); + expect(restored.get("/codex.jsonl")).toEqual(original.get("/codex.jsonl")); + }); + + it("drops an entry whose persisted parse state is corrupt", () => { + // Resuming with a bad reducer state would attach appended usage to the + // wrong model or replay fork-copied history; that entry must cold parse. + const encoded = encodeScanCache(cacheWith([["/a.jsonl", 100, [record()]]])); + const poisoned = { + ...encoded, + files: { + "/a.jsonl": { ...encoded.files["/a.jsonl"]!, cs: { model: 42 } }, + }, + }; + + expect(decodeScanCache(JSON.parse(JSON.stringify(poisoned))).has("/a.jsonl")).toBe(false); + }); + + it("drops an entry whose guard length is outside the supported range", () => { + // The guard length sizes a Buffer in the reader; a bogus value would make + // every parse of that file fail and silently drop its usage. + const encoded = encodeScanCache(cacheWith([["/a.jsonl", 100, [record()]]])); + const poisoned = { + ...encoded, + files: { "/a.jsonl": { ...encoded.files["/a.jsonl"]!, gl: 1e20 } }, + }; + + expect(decodeScanCache(JSON.parse(JSON.stringify(poisoned))).has("/a.jsonl")).toBe(false); + }); + + it("rejects a document from the previous cache version", () => { + const encoded = encodeScanCache(cacheWith([["/a.jsonl", 100, [record()]]])); + const previous = { ...encoded, version: 2 }; + + expect(decodeScanCache(JSON.parse(JSON.stringify(previous))).size).toBe(0); }); it("interns repeated model and session strings", () => { @@ -79,7 +159,7 @@ describe("scan cache round trip", () => { it("rejects the whole cache when an intern table holds a non-string", () => { // models: [1] would pass the undefined guard, put a number in a record's - // model, and crash normalizeModelName at aggregate time. + // model, and crash lookupRate at aggregate time. const encoded = encodeScanCache(cacheWith([["/a.jsonl", 100, [record()]]])); const poisoned = { ...encoded, models: [1] }; @@ -184,6 +264,20 @@ describe("pruneScanCache with an unwalked root", () => { expect(removed).toBe(0); expect(cache.size).toBe(1); }); + + it("keeps entries under a sibling path that only shares the walked root prefix", () => { + const cache = cacheWith([["/claude/projects-copy/a.jsonl", 5000, [record()]]]); + + const removed = pruneScanCache(cache, { + livePaths: new Set(), + walkedRoots: ["/claude/projects"], + windowStartMs: 4000, + retentionCutoffMs: 1000, + }); + + expect(removed).toBe(0); + expect(cache.size).toBe(1); + }); }); describe("dedupeWithinFile", () => { diff --git a/apps/server/src/usage/usageScanCache.ts b/apps/server/src/usage/usageScanCache.ts index cc15ee9cee62..102058a07d35 100644 --- a/apps/server/src/usage/usageScanCache.ts +++ b/apps/server/src/usage/usageScanCache.ts @@ -14,19 +14,33 @@ * * @module usageScanCache */ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodePath from "node:path"; + import type { UsageProviderKind } from "@t3tools/contracts"; -import type { UsageRecord } from "./usageTranscripts.ts"; +import { GUARD_LENGTH, type TranscriptParsePosition } from "./usageTranscriptReader.ts"; +import type { CodexScanState, UsageRecord } from "./usageTranscripts.ts"; // v2: Codex fork-copy suppression changed what a file parses to, so v1 // entries would keep serving double-counted records forever. -export const USAGE_SCAN_CACHE_VERSION = 2 as const; +// v3: entries carry the parse position and reducer state so a grown file +// re-parses only its appended bytes instead of starting over. +export const USAGE_SCAN_CACHE_VERSION = 3 as const; export interface CachedFile { readonly size: number; readonly mtimeMs: number; readonly provider: UsageProviderKind; + /** Records from newline-terminated lines, up to `position.resumeOffset`. */ readonly records: readonly UsageRecord[]; + /** + * Records from a trailing segment the writer had not newline-terminated at + * parse time. Kept apart from `records` because an incremental parse + * re-reads that segment and would otherwise double count it. + */ + readonly tailRecords: readonly UsageRecord[]; + readonly position: TranscriptParsePosition; } export type ScanCache = Map; @@ -54,6 +68,14 @@ interface SerializedFile { readonly m: number; readonly p: UsageProviderKind; readonly r: readonly SerializedRecord[]; + /** Tail records; see `CachedFile.tailRecords`. */ + readonly t: readonly SerializedRecord[]; + /** Parse position: resume offset, guard length, guard hash. */ + readonly o: number; + readonly gl: number; + readonly gh: number; + /** Codex reducer state at `o`; `null` for stateless providers. */ + readonly cs: CodexScanState | null; } interface SerializedCache { @@ -79,24 +101,31 @@ export function encodeScanCache(cache: ScanCache): SerializedCache { return next; }; + const serializeRecord = (record: UsageRecord): SerializedRecord => [ + record.timestampMs, + intern(models, modelIndex, record.model), + intern(sessions, sessionIndex, record.sessionId), + record.totals.uncachedInputTokens, + record.totals.cachedInputTokens, + record.totals.cacheCreationTokens, + record.totals.outputTokens, + record.totals.reasoningTokens, + record.dedupeKey, + record.reportedCostUsd, + ]; + const files: Record = {}; for (const [path, entry] of cache) { files[path] = { s: entry.size, m: entry.mtimeMs, p: entry.provider, - r: entry.records.map((record) => [ - record.timestampMs, - intern(models, modelIndex, record.model), - intern(sessions, sessionIndex, record.sessionId), - record.totals.uncachedInputTokens, - record.totals.cachedInputTokens, - record.totals.cacheCreationTokens, - record.totals.outputTokens, - record.totals.reasoningTokens, - record.dedupeKey, - record.reportedCostUsd, - ]), + r: entry.records.map(serializeRecord), + t: entry.tailRecords.map(serializeRecord), + o: entry.position.resumeOffset, + gl: entry.position.guardLength, + gh: entry.position.guardHash, + cs: entry.position.codexState, }; } @@ -124,30 +153,22 @@ export function decodeScanCache(document: unknown): ScanCache { // The intern tables must be all strings: a numeric entry would pass the // undefined guard below, land in a record's model, and crash the aggregate - // at normalizeModelName. A corrupt table rejects the whole cache. + // at lookupRate. A corrupt table rejects the whole cache. if (!root.models.every((value) => typeof value === "string")) return cache; if (!root.sessions.every((value) => typeof value === "string")) return cache; const models = root.models as readonly string[]; const sessions = root.sessions as readonly string[]; - for (const [path, raw] of Object.entries(root.files)) { - if (typeof raw !== "object" || raw === null) continue; - const entry = raw as Partial; - if (typeof entry.s !== "number" || typeof entry.m !== "number") continue; - if (entry.p !== "claude" && entry.p !== "codex") continue; - if (!isRecordArray(entry.r)) continue; - - const provider: UsageProviderKind = entry.p; + // Any corrupt row disqualifies the whole entry. Keeping the survivors + // under the original (size, mtime) would read as a valid warm hit and the + // file would never be re-parsed, silently losing the dropped rows' usage. + const decodeRecords = ( + rows: readonly unknown[], + provider: UsageProviderKind, + ): UsageRecord[] | null => { const records: UsageRecord[] = []; - // Any corrupt row disqualifies the whole entry. Keeping the survivors - // under the original (size, mtime) would read as a valid warm hit and the - // file would never be re-parsed, silently losing the dropped rows' usage. - let corrupt = false; - for (const row of entry.r) { - if (!isRecordArray(row) || row.length < 10) { - corrupt = true; - break; - } + for (const row of rows) { + if (!isRecordArray(row) || row.length < 10) return null; const [ timestampMs, modelIndex, @@ -172,8 +193,7 @@ export function decodeScanCache(document: unknown): ScanCache { !Number.isFinite(output) || !Number.isFinite(reasoning) ) { - corrupt = true; - break; + return null; } records.push({ @@ -192,14 +212,89 @@ export function decodeScanCache(document: unknown): ScanCache { dedupeKey: typeof dedupeKey === "string" ? dedupeKey : null, }); } + return records; + }; - if (corrupt) continue; - cache.set(path, { size: entry.s, mtimeMs: entry.m, provider, records }); + for (const [path, raw] of Object.entries(root.files)) { + if (typeof raw !== "object" || raw === null) continue; + const entry = raw as Partial; + if (typeof entry.s !== "number" || typeof entry.m !== "number") continue; + if (entry.p !== "claude" && entry.p !== "codex" && entry.p !== "grok") continue; + if (!isRecordArray(entry.r) || !isRecordArray(entry.t)) continue; + // Position fields feed byte offsets and a Buffer allocation in the reader, + // so anything outside their real ranges must reject the entry: a bogus + // guard length would otherwise fail every parse of the file, silently + // dropping its usage instead of costing the documented cold re-parse. + if ( + typeof entry.o !== "number" || + !Number.isSafeInteger(entry.o) || + entry.o < 0 || + typeof entry.gl !== "number" || + !Number.isSafeInteger(entry.gl) || + entry.gl < 0 || + entry.gl > GUARD_LENGTH || + entry.gl > entry.o || + typeof entry.gh !== "number" || + !Number.isFinite(entry.gh) + ) { + continue; + } + const codexState = decodeCodexState(entry.cs); + if (codexState === undefined) continue; + + const provider: UsageProviderKind = entry.p; + const records = decodeRecords(entry.r, provider); + const tailRecords = decodeRecords(entry.t, provider); + if (records === null || tailRecords === null) continue; + + cache.set(path, { + size: entry.s, + mtimeMs: entry.m, + provider, + records, + tailRecords, + position: { + resumeOffset: entry.o, + guardLength: entry.gl, + guardHash: entry.gh, + codexState, + }, + }); } return cache; } +/** + * Validates a persisted Codex reducer state. Returns `undefined` for a corrupt + * value, which disqualifies the entry: resuming with a bad state would attach + * appended usage to the wrong model or replay fork-copied history. + */ +function decodeCodexState(value: unknown): CodexScanState | null | undefined { + if (value === null) return null; + if (typeof value !== "object") return undefined; + const state = value as Partial; + if ( + typeof state.model !== "string" || + typeof state.sessionId !== "string" || + (state.lastUsageSignature !== null && typeof state.lastUsageSignature !== "string") || + typeof state.sawSessionMeta !== "boolean" || + typeof state.suppressingForkCopies !== "boolean" || + typeof state.forkCopyAnchorMs !== "number" || + !Number.isFinite(state.forkCopyAnchorMs) + ) { + return undefined; + } + return { + model: state.model, + sessionId: state.sessionId, + lastUsageSignature: state.lastUsageSignature ?? null, + sawSessionMeta: state.sawSessionMeta, + suppressingForkCopies: state.suppressingForkCopies, + forkCopyAnchorMs: state.forkCopyAnchorMs, + }; +} + export interface PruneOptions { /** Files the walk just saw. Only meaningful inside the walked window. */ readonly livePaths: ReadonlySet; @@ -229,7 +324,15 @@ export function pruneScanCache(cache: ScanCache, options: PruneOptions): number let removed = 0; for (const [path, entry] of cache) { const agedOut = entry.mtimeMs < options.retentionCutoffMs; - const underWalkedRoot = options.walkedRoots.some((root) => path.startsWith(root)); + const underWalkedRoot = options.walkedRoots.some((root) => { + const relative = NodePath.relative(root, path); + return ( + relative === "" || + (relative !== ".." && + !relative.startsWith(`..${NodePath.sep}`) && + !NodePath.isAbsolute(relative)) + ); + }); const deleted = underWalkedRoot && entry.mtimeMs >= options.windowStartMs && !options.livePaths.has(path); if (agedOut || deleted) { @@ -240,9 +343,17 @@ export function pruneScanCache(cache: ScanCache, options: PruneOptions): number return removed; } -/** Within-file de-duplication, applied before an entry is cached. */ -export function dedupeWithinFile(records: readonly UsageRecord[]): readonly UsageRecord[] { - const seen = new Set(); +/** + * Within-file de-duplication, applied before an entry is cached. + * + * Callers stitching an incremental parse together pass one `seen` set across + * the line and tail record batches so the whole file stays deduplicated as a + * unit; the set is mutated in place. + */ +export function dedupeWithinFile( + records: readonly UsageRecord[], + seen: Set = new Set(), +): readonly UsageRecord[] { const kept: UsageRecord[] = []; for (const record of records) { if (record.dedupeKey !== null) { diff --git a/apps/server/src/usage/usageTranscriptReader.test.ts b/apps/server/src/usage/usageTranscriptReader.test.ts new file mode 100644 index 000000000000..5feb68b2ff58 --- /dev/null +++ b/apps/server/src/usage/usageTranscriptReader.test.ts @@ -0,0 +1,210 @@ +// @effect-diagnostics nodeBuiltinImport:off - resume coverage writes, appends +// to, and truncates real transcript files byte-exactly, mirroring the reader's +// own deliberate node:fs usage. +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import { afterEach, assert, beforeEach, describe, it } from "@effect/vitest"; + +import { readTranscriptRecords } from "./usageTranscriptReader.ts"; + +let dir: string; + +beforeEach(async () => { + dir = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "usage-reader-test-")); +}); + +afterEach(async () => { + await NodeFSP.rm(dir, { recursive: true, force: true }); +}); + +function claudeLine(id: number, outputTokens: number): string { + return `${JSON.stringify({ + type: "assistant", + timestamp: "2026-08-01T10:00:00Z", + requestId: `req_${id}`, + sessionId: "session-1", + message: { + id: `msg_${id}`, + model: "claude-fable-5", + usage: { input_tokens: 10, output_tokens: outputTokens }, + }, + })}\n`; +} + +function codexMetaLine(): string { + return `${JSON.stringify({ + type: "session_meta", + timestamp: "2026-08-01T10:00:00Z", + payload: { type: "session_meta", id: "codex-session-1" }, + })}\n`; +} + +function codexModelLine(model: string): string { + return `${JSON.stringify({ + type: "turn_context", + timestamp: "2026-08-01T10:00:01Z", + payload: { type: "turn_context", model }, + })}\n`; +} + +function codexUsageLine(outputTokens: number, secondsOffset: number): string { + return `${JSON.stringify({ + type: "event_msg", + timestamp: `2026-08-01T10:00:${String(secondsOffset).padStart(2, "0")}Z`, + payload: { + type: "token_count", + info: { last_token_usage: { input_tokens: 100, output_tokens: outputTokens } }, + }, + })}\n`; +} + +describe("readTranscriptRecords resume", () => { + it("parses only appended lines when resuming a grown file", async () => { + const path = NodePath.join(dir, "claude.jsonl"); + await NodeFSP.writeFile(path, claudeLine(1, 5) + claudeLine(2, 7)); + const first = await readTranscriptRecords(path, "claude"); + assert.isNotNull(first); + assert.strictEqual(first.records.length, 2); + assert.isFalse(first.resumed); + + await NodeFSP.appendFile(path, claudeLine(3, 11)); + const second = await readTranscriptRecords(path, "claude", first.position); + assert.isNotNull(second); + assert.isTrue(second.resumed); + assert.strictEqual(second.records.length, 1); + assert.strictEqual(second.records[0]?.totals.outputTokens, 11); + + // The stitched result matches a from-scratch parse of the whole file. + const full = await readTranscriptRecords(path, "claude"); + assert.isNotNull(full); + assert.deepStrictEqual([...first.records, ...second.records], [...full.records]); + }); + + it("carries the Codex reducer state across the resume boundary", async () => { + const path = NodePath.join(dir, "rollout.jsonl"); + await NodeFSP.writeFile(path, codexMetaLine() + codexModelLine("gpt-5.2-codex")); + const first = await readTranscriptRecords(path, "codex"); + assert.isNotNull(first); + assert.strictEqual(first.records.length, 0); + + // The appended usage event has no turn_context or session_meta of its own; + // model and session must come from the state captured before the boundary. + await NodeFSP.appendFile(path, codexUsageLine(9, 5)); + const second = await readTranscriptRecords(path, "codex", first.position); + assert.isNotNull(second); + assert.isTrue(second.resumed); + assert.strictEqual(second.records.length, 1); + assert.strictEqual(second.records[0]?.model, "gpt-5.2-codex"); + assert.strictEqual(second.records[0]?.sessionId, "codex-session-1"); + }); + + it("suppresses a Codex duplicate usage event that straddles the boundary", async () => { + const path = NodePath.join(dir, "rollout.jsonl"); + await NodeFSP.writeFile( + path, + codexMetaLine() + codexModelLine("gpt-5.2-codex") + codexUsageLine(9, 5), + ); + const first = await readTranscriptRecords(path, "codex"); + assert.isNotNull(first); + assert.strictEqual(first.records.length, 1); + + // Codex re-emits an unchanged token_count on stream boundaries; the copy + // lands after the resume point and must still be dropped. + await NodeFSP.appendFile(path, codexUsageLine(9, 5) + codexUsageLine(21, 8)); + const second = await readTranscriptRecords(path, "codex", first.position); + assert.isNotNull(second); + assert.isTrue(second.resumed); + assert.deepStrictEqual( + second.records.map((record) => record.totals.outputTokens), + [21], + ); + }); + + it("defers an unterminated trailing line to tailRecords, then consumes it once terminated", async () => { + const path = NodePath.join(dir, "claude.jsonl"); + const unterminated = claudeLine(2, 7).trimEnd(); + await NodeFSP.writeFile(path, claudeLine(1, 5) + unterminated); + const first = await readTranscriptRecords(path, "claude"); + assert.isNotNull(first); + assert.strictEqual(first.records.length, 1); + assert.strictEqual(first.tailRecords.length, 1); + assert.strictEqual(first.tailRecords[0]?.totals.outputTokens, 7); + + // Completing the line and appending another re-reads from the resume + // point, so the once-tail record arrives exactly once as a line record. + await NodeFSP.appendFile(path, `\n${claudeLine(3, 11)}`); + const second = await readTranscriptRecords(path, "claude", first.position); + assert.isNotNull(second); + assert.isTrue(second.resumed); + assert.deepStrictEqual( + second.records.map((record) => record.totals.outputTokens), + [7, 11], + ); + assert.strictEqual(second.tailRecords.length, 0); + }); + + it("re-parses from the start when the guard bytes no longer match", async () => { + const path = NodePath.join(dir, "claude.jsonl"); + await NodeFSP.writeFile(path, claudeLine(1, 5)); + const first = await readTranscriptRecords(path, "claude"); + assert.isNotNull(first); + + // Same path, larger size, different content: a replaced file, not growth. + await NodeFSP.writeFile(path, claudeLine(4, 13) + claudeLine(5, 17)); + const second = await readTranscriptRecords(path, "claude", first.position); + assert.isNotNull(second); + assert.isFalse(second.resumed); + assert.deepStrictEqual( + second.records.map((record) => record.totals.outputTokens), + [13, 17], + ); + }); + + it("re-parses from the start when the file shrank below the resume point", async () => { + const path = NodePath.join(dir, "claude.jsonl"); + await NodeFSP.writeFile(path, claudeLine(1, 5) + claudeLine(2, 7)); + const first = await readTranscriptRecords(path, "claude"); + assert.isNotNull(first); + + await NodeFSP.writeFile(path, claudeLine(3, 11)); + const second = await readTranscriptRecords(path, "claude", first.position); + assert.isNotNull(second); + assert.isFalse(second.resumed); + assert.deepStrictEqual( + second.records.map((record) => record.totals.outputTokens), + [11], + ); + }); + + it("parses a line larger than one stream chunk", async () => { + // Tool-heavy transcripts carry multi-megabyte single lines; they arrive + // split across many chunks and must reassemble into one record. + const path = NodePath.join(dir, "claude.jsonl"); + const bigLine = `${JSON.stringify({ + type: "assistant", + timestamp: "2026-08-01T10:00:00Z", + requestId: "req_big", + sessionId: "session-1", + padding: "x".repeat(512 * 1024), + message: { + id: "msg_big", + model: "claude-fable-5", + usage: { input_tokens: 10, output_tokens: 42 }, + }, + })}\n`; + await NodeFSP.writeFile(path, bigLine + claudeLine(2, 7)); + + const parsed = await readTranscriptRecords(path, "claude"); + assert.isNotNull(parsed); + assert.deepStrictEqual( + parsed.records.map((record) => record.totals.outputTokens), + [42, 7], + ); + }); + + it("returns null for an unreadable file", async () => { + assert.isNull(await readTranscriptRecords(NodePath.join(dir, "missing.jsonl"), "claude")); + }); +}); diff --git a/apps/server/src/usage/usageTranscriptReader.ts b/apps/server/src/usage/usageTranscriptReader.ts index c72f0c24db65..9e5ab6e0c9e0 100644 --- a/apps/server/src/usage/usageTranscriptReader.ts +++ b/apps/server/src/usage/usageTranscriptReader.ts @@ -4,16 +4,19 @@ * * Isolated here so the rest of the usage code stays on Effect's `FileSystem`. * The direct `node:fs` streaming is deliberate: a cold 30-day window is ~1.4 GB - * across ~1,500 files, and `readline` over a read stream is roughly an order of + * across ~1,500 files, and buffer-level streaming is roughly an order of * magnitude cheaper than materialising each file. The equivalent Effect stream * pipeline is idiomatic but not fast enough to sit behind a page load. * + * Transcripts are append-only, so a parse also reports the byte position it + * stopped at. A later scan of the same file resumes from that position and + * parses only the appended bytes, which is what keeps a warm scan cheap while a + * session is actively writing a multi-hundred-megabyte rollout. + * * @module usageTranscriptReader */ -import * as NodeFS from "node:fs"; import * as NodeFSP from "node:fs/promises"; import * as NodePath from "node:path"; -import * as NodeReadline from "node:readline"; import type { UsageProviderKind } from "@t3tools/contracts"; @@ -22,6 +25,8 @@ import { mightCarryUsage, parseClaudeLine, parseCodexLine, + parseGrokLine, + type CodexScanState, type UsageRecord, } from "./usageTranscripts.ts"; @@ -31,18 +36,74 @@ export interface TranscriptFile { readonly mtimeMs: number; } +/** + * Where a parse stopped, with enough state to continue from there. + * + * The guard hash fingerprints the bytes immediately before `resumeOffset`. A + * resume only proceeds when those bytes still match: transcripts are + * append-only by design, but a rotated or rewritten file silently mis-parsed + * from the middle would corrupt usage totals. The window is a cheap tripwire + * for those realistic failure shapes, all of which disturb the file's tail at + * that exact offset; it deliberately does not hash the whole prefix, which + * would cost the full re-read the resume exists to avoid. + */ +export interface TranscriptParsePosition { + /** Byte offset just past the last newline-terminated line consumed. */ + readonly resumeOffset: number; + /** Length of the fingerprinted window ending at `resumeOffset`. */ + readonly guardLength: number; + /** FNV-1a hash of that window. */ + readonly guardHash: number; + /** Codex reducer state as of `resumeOffset`; `null` for stateless providers. */ + readonly codexState: CodexScanState | null; +} + +export interface TranscriptParseResult { + /** Records from newline-terminated lines at or after the parse start. */ + readonly records: readonly UsageRecord[]; + /** + * Records from a trailing segment the writer has not newline-terminated yet. + * Kept out of `records` because `position` deliberately excludes that + * segment: the next scan re-reads it once the writer finishes the line. + */ + readonly tailRecords: readonly UsageRecord[]; + readonly position: TranscriptParsePosition; + /** Whether the parse continued from `resumeFrom` rather than byte 0. */ + readonly resumed: boolean; +} + +/** 64 bytes of JSONL tail is ample to distinguish a replaced file. */ +export const GUARD_LENGTH = 64; +const NEWLINE = 0x0a; +const CARRIAGE_RETURN = 0x0d; + +function fnv1a(buffer: Buffer): number { + let hash = 0x811c9dc5; + for (let index = 0; index < buffer.length; index += 1) { + hash ^= buffer[index]!; + hash = Math.imul(hash, 0x01000193); + } + return hash >>> 0; +} + /** * Lists `.jsonl` transcripts under `root` last modified at or after `sinceMs`. * * Errors on individual entries are swallowed: session files rotate and get * removed while the walk is in flight, and a partial listing is far better than * failing the page. + * + * `fileName` restricts the walk to a single basename (Grok's `updates.jsonl`). + * Grok sessions also ship multi-megabyte `chat_history` and `events` logs that + * never carry usage, so the basename filter keeps a cold scan off those files. */ export async function listTranscriptFiles( root: string, sinceMs: number, + options?: { readonly fileName?: string }, ): Promise { const found: TranscriptFile[] = []; + const fileName = options?.fileName; const walk = async (dir: string): Promise => { let entries; @@ -57,7 +118,11 @@ export async function listTranscriptFiles( await walk(child); continue; } - if (!entry.name.endsWith(".jsonl")) continue; + if (fileName !== undefined) { + if (entry.name !== fileName) continue; + } else if (!entry.name.endsWith(".jsonl")) { + continue; + } try { const stats = await NodeFSP.stat(child); if (stats.mtimeMs >= sinceMs) { @@ -89,6 +154,25 @@ export async function readDirectoryVolumeId(path: string): Promise { } } +async function guardMatches( + handle: NodeFSP.FileHandle, + position: TranscriptParsePosition, +): Promise { + if (position.guardLength <= 0 || position.guardLength > GUARD_LENGTH) return false; + try { + const window = Buffer.alloc(position.guardLength); + const { bytesRead } = await handle.read( + window, + 0, + position.guardLength, + position.resumeOffset - position.guardLength, + ); + return bytesRead === position.guardLength && fnv1a(window) === position.guardHash; + } catch { + return false; + } +} + /** * Streams one transcript and returns the usage records it contains, or `null` * when the file could not be read. @@ -98,6 +182,10 @@ export async function readDirectoryVolumeId(path: string): Promise { * under the same `(size, mtime)` key would silently drop that file's usage * until the file next changes. * + * With `resumeFrom`, parsing continues from that position when its guard bytes + * still match, so only appended lines are read; otherwise the whole file is + * re-parsed from the start and `resumed` reports `false`. + * * Codex carries the active model on `turn_context` lines that hold no usage of * their own, so those still have to pass through the reducer to keep model * attribution correct. @@ -105,37 +193,121 @@ export async function readDirectoryVolumeId(path: string): Promise { export async function readTranscriptRecords( filePath: string, provider: UsageProviderKind, -): Promise { - const records: UsageRecord[] = []; - const codexState = initialCodexScanState(); + resumeFrom?: TranscriptParsePosition, +): Promise { + let handle: NodeFSP.FileHandle; + try { + handle = await NodeFSP.open(filePath, "r"); + } catch { + return null; + } try { - const lines = NodeReadline.createInterface({ - input: NodeFS.createReadStream(filePath, { encoding: "utf8" }), - crlfDelay: Infinity, - }); + let codexState = initialCodexScanState(); + let resumed = false; + let start = 0; + if ( + resumeFrom !== undefined && + resumeFrom.resumeOffset > 0 && + (provider !== "codex" || resumeFrom.codexState !== null) && + (await guardMatches(handle, resumeFrom)) + ) { + if (resumeFrom.codexState !== null) codexState = { ...resumeFrom.codexState }; + start = resumeFrom.resumeOffset; + resumed = true; + } - for await (const line of lines) { + const parseLine = (line: string, state: CodexScanState, out: UsageRecord[]): void => { if (provider === "codex") { if ( !mightCarryUsage(line, provider) && !line.includes('"turn_context"') && !line.includes('"session_meta"') ) { - continue; + return; } - const record = parseCodexLine(line, codexState); - if (record !== null) records.push(record); + const record = parseCodexLine(line, state); + if (record !== null) out.push(record); + return; + } + if (!mightCarryUsage(line, provider)) return; + if (provider === "grok") { + for (const grokRecord of parseGrokLine(line)) out.push(grokRecord); + return; + } + const record = parseClaudeLine(line); + if (record !== null) out.push(record); + }; + + const toLineString = (lineBuffer: Buffer): string => { + const content = + lineBuffer.length > 0 && lineBuffer[lineBuffer.length - 1] === CARRIAGE_RETURN + ? lineBuffer.subarray(0, -1) + : lineBuffer; + return content.toString("utf8"); + }; + + const records: UsageRecord[] = []; + // Buffer-level line splitting rather than `readline`, because resuming + // needs byte-exact offsets and decoded strings cannot provide them. + // Newline-free chunks are collected rather than concatenated as they + // arrive, so a single huge line costs one copy instead of one per chunk. + let resumeOffset = start; + let pendingChunks: Buffer[] = []; + const stream = handle.createReadStream({ + start, + autoClose: false, + }) as AsyncIterable; + for await (const chunk of stream) { + if (!chunk.includes(NEWLINE)) { + pendingChunks.push(chunk); continue; } + const buffer: Buffer = + pendingChunks.length === 0 ? chunk : Buffer.concat([...pendingChunks, chunk]); + pendingChunks = []; + let lineStart = 0; + for (;;) { + const newlineIndex = buffer.indexOf(NEWLINE, lineStart); + if (newlineIndex === -1) break; + parseLine(toLineString(buffer.subarray(lineStart, newlineIndex)), codexState, records); + lineStart = newlineIndex + 1; + } + resumeOffset += lineStart; + if (lineStart < buffer.length) pendingChunks.push(buffer.subarray(lineStart)); + } - if (!mightCarryUsage(line, provider)) continue; - const record = parseClaudeLine(line); - if (record !== null) records.push(record); + // A trailing segment without its newline is parsed for this result but not + // consumed: a writer may still be appending to it, and counting a half + // record now and its full form later would double count. + const tailRecords: UsageRecord[] = []; + if (pendingChunks.length > 0) { + const pending = pendingChunks.length === 1 ? pendingChunks[0]! : Buffer.concat(pendingChunks); + if (pending.length > 0) parseLine(toLineString(pending), { ...codexState }, tailRecords); } + + const guardLength = Math.min(GUARD_LENGTH, resumeOffset); + let guardHash = 0; + if (guardLength > 0) { + const window = Buffer.alloc(guardLength); + await handle.read(window, 0, guardLength, resumeOffset - guardLength); + guardHash = fnv1a(window); + } + + return { + records, + tailRecords, + position: { + resumeOffset, + guardLength, + guardHash, + codexState: provider === "codex" ? codexState : null, + }, + resumed, + }; } catch { return null; + } finally { + await handle.close().catch(() => undefined); } - - return records; } diff --git a/apps/server/src/usage/usageTranscripts.test.ts b/apps/server/src/usage/usageTranscripts.test.ts index 8f86a3d836bd..b09db613ed85 100644 --- a/apps/server/src/usage/usageTranscripts.test.ts +++ b/apps/server/src/usage/usageTranscripts.test.ts @@ -1,9 +1,11 @@ import { describe, expect, it } from "@effect/vitest"; import { + GROK_COST_USD_TICKS_PER_DOLLAR, initialCodexScanState, parseClaudeLine, parseCodexLine, + parseGrokLine, totalTokens, } from "./usageTranscripts.ts"; @@ -249,3 +251,316 @@ describe("totalTokens", () => { ).toBe(100); }); }); + +describe("parseGrokLine", () => { + /** Shaped after a real Grok Build `turn_completed` session update. */ + function turnCompleted(overrides?: { + sessionId?: string; + promptId?: string; + timestamp?: number; + agentTimestampMs?: number; + usage?: Record; + modelUsage?: Record> | null; + }): string { + const modelUsage = + overrides && "modelUsage" in overrides + ? overrides.modelUsage + : { + "grok-4.5-build": { + inputTokens: 20_272, + outputTokens: 272, + totalTokens: 20_544, + cachedReadTokens: 11_264, + cacheCreationTokens: 0, + reasoningTokens: 180, + costUsdTicks: 230_272_000, + }, + }; + + return JSON.stringify({ + timestamp: overrides?.timestamp ?? 1_786_372_566, + method: "_x.ai/session/update", + params: { + sessionId: overrides?.sessionId ?? "019fec1a-12f7-72f2-9b1f-7778a00aea3c", + update: { + sessionUpdate: "turn_completed", + prompt_id: overrides?.promptId ?? "prompt-1", + stop_reason: "end_turn", + usage: { + inputTokens: 20_272, + outputTokens: 272, + totalTokens: 20_544, + cachedReadTokens: 11_264, + cacheCreationTokens: 0, + reasoningTokens: 180, + costUsdTicks: 230_272_000, + ...(modelUsage === null ? {} : { modelUsage }), + ...overrides?.usage, + }, + }, + _meta: { + eventId: "event-1", + agentTimestampMs: overrides?.agentTimestampMs ?? 1_786_372_566_485, + }, + }, + }); + } + + it("extracts per-model totals and provider-reported cost ticks", () => { + const records = parseGrokLine(turnCompleted()); + + expect(records).toHaveLength(1); + const [record] = records; + expect(record?.provider).toBe("grok"); + expect(record?.model).toBe("grok-4.5-build"); + expect(record?.sessionId).toBe("019fec1a-12f7-72f2-9b1f-7778a00aea3c"); + expect(record?.timestampMs).toBe(1_786_372_566_485); + expect(record?.totals).toEqual({ + uncachedInputTokens: 20_272 - 11_264, + cachedInputTokens: 11_264, + cacheCreationTokens: 0, + outputTokens: 272, + reasoningTokens: 180, + }); + expect(record?.reportedCostUsd).toBeCloseTo(230_272_000 / GROK_COST_USD_TICKS_PER_DOLLAR, 12); + expect(record?.dedupeKey).toBe("019fec1a-12f7-72f2-9b1f-7778a00aea3c:prompt-1:grok-4.5-build"); + }); + + it("emits one record per model when modelUsage has several entries", () => { + const records = parseGrokLine( + turnCompleted({ + modelUsage: { + "grok-4.5": { + inputTokens: 1000, + outputTokens: 50, + cachedReadTokens: 400, + reasoningTokens: 20, + costUsdTicks: 50_000_000, + }, + "grok-composer-2.5-fast": { + inputTokens: 200, + outputTokens: 30, + cachedReadTokens: 100, + reasoningTokens: 0, + costUsdTicks: 10_000_000, + }, + }, + }), + ); + + expect(records.map((record) => record.model).toSorted()).toEqual([ + "grok-4.5", + "grok-composer-2.5-fast", + ]); + expect(records.every((record) => record.provider === "grok")).toBe(true); + expect(records.find((record) => record.model === "grok-4.5")?.reportedCostUsd).toBeCloseTo( + 0.005, + 12, + ); + }); + + it("inherits top-level cost ticks for a single model without its own ticks", () => { + const records = parseGrokLine( + turnCompleted({ + modelUsage: { + "grok-4.5-build": { + inputTokens: 1000, + outputTokens: 10, + cachedReadTokens: 0, + reasoningTokens: 0, + }, + }, + usage: { costUsdTicks: GROK_COST_USD_TICKS_PER_DOLLAR }, + }), + ); + + expect(records).toHaveLength(1); + expect(records[0]?.reportedCostUsd).toBe(1); + }); + + it("falls back to a generic grok model when modelUsage is absent", () => { + const records = parseGrokLine(turnCompleted({ modelUsage: null })); + + expect(records).toHaveLength(1); + const [record] = records; + expect(record?.provider).toBe("grok"); + expect(record?.model).toBe("grok"); + expect(record?.totals).toEqual({ + uncachedInputTokens: 20_272 - 11_264, + cachedInputTokens: 11_264, + cacheCreationTokens: 0, + outputTokens: 272, + reasoningTokens: 180, + }); + expect(record?.reportedCostUsd).toBeCloseTo(230_272_000 / GROK_COST_USD_TICKS_PER_DOLLAR, 12); + expect(record?.dedupeKey).toBe("019fec1a-12f7-72f2-9b1f-7778a00aea3c:prompt-1:grok"); + }); + + it("pro-rates top-level cost ticks across multi-model turns without per-model ticks", () => { + const records = parseGrokLine( + turnCompleted({ + modelUsage: { + "grok-4.5": { + inputTokens: 300, + outputTokens: 0, + cachedReadTokens: 0, + reasoningTokens: 0, + }, + "grok-composer-2.5-fast": { + inputTokens: 100, + outputTokens: 0, + cachedReadTokens: 0, + reasoningTokens: 0, + }, + }, + usage: { costUsdTicks: GROK_COST_USD_TICKS_PER_DOLLAR }, + }), + ); + + expect(records).toHaveLength(2); + const byModel = Object.fromEntries(records.map((record) => [record.model, record])); + expect(byModel["grok-4.5"]?.reportedCostUsd).toBeCloseTo(0.75, 12); + expect(byModel["grok-composer-2.5-fast"]?.reportedCostUsd).toBeCloseTo(0.25, 12); + const sum = + (byModel["grok-4.5"]?.reportedCostUsd ?? 0) + + (byModel["grok-composer-2.5-fast"]?.reportedCostUsd ?? 0); + expect(sum).toBeCloseTo(1, 12); + }); + + it("pro-rates aggregate cost when a zero-token sibling carries costUsdTicks: 0", () => { + const records = parseGrokLine( + turnCompleted({ + modelUsage: { + "grok-4.5": { + inputTokens: 300, + outputTokens: 0, + cachedReadTokens: 0, + reasoningTokens: 0, + }, + "grok-composer-2.5-fast": { + inputTokens: 100, + outputTokens: 0, + cachedReadTokens: 0, + reasoningTokens: 0, + }, + "empty-sibling": { + inputTokens: 0, + outputTokens: 0, + cachedReadTokens: 0, + reasoningTokens: 0, + costUsdTicks: 0, + }, + }, + usage: { costUsdTicks: GROK_COST_USD_TICKS_PER_DOLLAR }, + }), + ); + + expect(records).toHaveLength(2); + expect(records.every((record) => record.model !== "empty-sibling")).toBe(true); + const byModel = Object.fromEntries(records.map((record) => [record.model, record])); + expect(byModel["grok-4.5"]?.reportedCostUsd).toBeCloseTo(0.75, 12); + expect(byModel["grok-composer-2.5-fast"]?.reportedCostUsd).toBeCloseTo(0.25, 12); + const sum = + (byModel["grok-4.5"]?.reportedCostUsd ?? 0) + + (byModel["grok-composer-2.5-fast"]?.reportedCostUsd ?? 0); + expect(sum).toBeCloseTo(1, 12); + }); + + it("allocates leftover aggregate ticks to models that omit per-model ticks", () => { + const records = parseGrokLine( + turnCompleted({ + modelUsage: { + "grok-4.5": { + inputTokens: 300, + outputTokens: 0, + cachedReadTokens: 0, + reasoningTokens: 0, + costUsdTicks: 0.4 * GROK_COST_USD_TICKS_PER_DOLLAR, + }, + "grok-composer-2.5-fast": { + inputTokens: 100, + outputTokens: 0, + cachedReadTokens: 0, + reasoningTokens: 0, + }, + }, + usage: { costUsdTicks: GROK_COST_USD_TICKS_PER_DOLLAR }, + }), + ); + + expect(records).toHaveLength(2); + const byModel = Object.fromEntries(records.map((record) => [record.model, record])); + expect(byModel["grok-4.5"]?.reportedCostUsd).toBeCloseTo(0.4, 12); + expect(byModel["grok-composer-2.5-fast"]?.reportedCostUsd).toBeCloseTo(0.6, 12); + const sum = + (byModel["grok-4.5"]?.reportedCostUsd ?? 0) + + (byModel["grok-composer-2.5-fast"]?.reportedCostUsd ?? 0); + expect(sum).toBeCloseTo(1, 12); + }); + + it("does not invent a colliding dedupe key when prompt_id is missing", () => { + const line = JSON.stringify({ + timestamp: 1_786_372_566, + method: "_x.ai/session/update", + params: { + sessionId: "s1", + update: { + sessionUpdate: "turn_completed", + usage: { + inputTokens: 10, + outputTokens: 2, + modelUsage: { + "grok-4.5": { inputTokens: 10, outputTokens: 2 }, + }, + }, + }, + }, + }); + + expect(parseGrokLine(line)[0]?.dedupeKey).toBeNull(); + }); + + it("ignores non-turn lines and empty usage", () => { + expect(parseGrokLine(JSON.stringify({ method: "session/update", params: {} }))).toEqual([]); + expect(parseGrokLine("not json")).toEqual([]); + expect( + parseGrokLine( + turnCompleted({ + modelUsage: { + "grok-4.5-build": { + inputTokens: 0, + outputTokens: 0, + cachedReadTokens: 0, + reasoningTokens: 0, + costUsdTicks: 0, + }, + }, + }), + ), + ).toEqual([]); + }); + + it("falls back to the outer unix-seconds timestamp when agent meta is missing", () => { + const line = JSON.stringify({ + timestamp: 1_786_372_566, + method: "_x.ai/session/update", + params: { + sessionId: "s1", + update: { + sessionUpdate: "turn_completed", + prompt_id: "p1", + usage: { + inputTokens: 10, + outputTokens: 2, + modelUsage: { + "grok-4.5": { inputTokens: 10, outputTokens: 2 }, + }, + }, + }, + }, + }); + + const records = parseGrokLine(line); + expect(records[0]?.timestampMs).toBe(1_786_372_566_000); + }); +}); diff --git a/apps/server/src/usage/usageTranscripts.ts b/apps/server/src/usage/usageTranscripts.ts index 49f9a1935ccc..2aea60709666 100644 --- a/apps/server/src/usage/usageTranscripts.ts +++ b/apps/server/src/usage/usageTranscripts.ts @@ -1,8 +1,8 @@ /** * Pure parsers for the provider CLIs' on-disk session transcripts. * - * Both parsers are line-at-a-time reducers so callers can stream large files - * without materialising them. Neither touches the filesystem. + * Each parser is a line-at-a-time reducer so callers can stream large files + * without materialising them. None of them touch the filesystem. * * @module usageTranscripts */ @@ -68,7 +68,20 @@ export function totalTokens(totals: UsageTokenTotals): number { * an order of magnitude. */ export function mightCarryUsage(line: string, provider: UsageProviderKind): boolean { - return provider === "claude" ? line.includes('"usage"') : line.includes('"token_count"'); + if (provider === "claude") return line.includes('"usage"'); + if (provider === "grok") return line.includes('"turn_completed"'); + return line.includes('"token_count"'); +} + +/** + * Grok reports cost in integer ticks where `1 USD = 10^10` ticks. See Grok + * headless `total_cost_usd_ticks`. Convert to dollars for pricing. + */ +export const GROK_COST_USD_TICKS_PER_DOLLAR = 10_000_000_000; + +export function grokCostTicksToUsd(ticks: unknown): number | null { + if (typeof ticks !== "number" || !Number.isFinite(ticks) || ticks < 0) return null; + return ticks / GROK_COST_USD_TICKS_PER_DOLLAR; } /* -------------------------------------------------------------------------- */ @@ -297,4 +310,179 @@ export function parseCodexLine(line: string, state: CodexScanState): UsageRecord }; } +/* -------------------------------------------------------------------------- */ +/* Grok Build */ +/* -------------------------------------------------------------------------- */ + +interface GrokUsageTotals { + readonly inputTokens: number; + readonly outputTokens: number; + readonly cachedReadTokens: number; + readonly cacheCreationTokens: number; + readonly reasoningTokens: number; + readonly costUsdTicks: number | null; +} + +function readGrokUsageTotals(value: unknown): GrokUsageTotals | null { + if (typeof value !== "object" || value === null) return null; + const record = value as Record; + return { + inputTokens: int(record["inputTokens"]), + outputTokens: int(record["outputTokens"]), + cachedReadTokens: int(record["cachedReadTokens"]), + cacheCreationTokens: int(record["cacheCreationTokens"]), + reasoningTokens: int(record["reasoningTokens"]), + costUsdTicks: + typeof record["costUsdTicks"] === "number" && Number.isFinite(record["costUsdTicks"]) + ? record["costUsdTicks"] + : null, + }; +} + +function grokTotalsToUsage(totals: GrokUsageTotals): UsageTokenTotals { + const cachedInputTokens = totals.cachedReadTokens; + const cacheCreationTokens = totals.cacheCreationTokens; + // Grok reports `inputTokens` inclusive of the cached portion, matching Codex. + const uncachedInputTokens = Math.max( + 0, + totals.inputTokens - cachedInputTokens - cacheCreationTokens, + ); + const outputTokens = totals.outputTokens; + return { + uncachedInputTokens, + cachedInputTokens, + cacheCreationTokens, + outputTokens, + reasoningTokens: Math.min(outputTokens, totals.reasoningTokens), + }; +} + +/** + * Parses one line of a Grok Build `updates.jsonl` session log. + * + * Usage lands on `turn_completed` session updates. Per-model breakdowns live + * under `usage.modelUsage`; when present each model becomes its own record. + * + * Returns every record for the line (0 or more). Callers stream line-by-line + * and flatten. + */ +export function parseGrokLine(line: string): readonly UsageRecord[] { + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + return []; + } + if (typeof parsed !== "object" || parsed === null) return []; + + const record = parsed as Record; + const params = record["params"]; + if (typeof params !== "object" || params === null) return []; + const paramsRecord = params as Record; + + const update = paramsRecord["update"]; + if (typeof update !== "object" || update === null) return []; + const updateRecord = update as Record; + if (updateRecord["sessionUpdate"] !== "turn_completed") return []; + + const usage = updateRecord["usage"]; + if (typeof usage !== "object" || usage === null) return []; + const usageRecord = usage as Record; + + const sessionId = typeof paramsRecord["sessionId"] === "string" ? paramsRecord["sessionId"] : ""; + const promptId = typeof updateRecord["prompt_id"] === "string" ? updateRecord["prompt_id"] : null; + + // Prefer the high-resolution agent clock; fall back to the outer unix seconds. + const meta = paramsRecord["_meta"]; + let timestampMs: number | null = null; + if (typeof meta === "object" && meta !== null) { + const agentTimestampMs = (meta as Record)["agentTimestampMs"]; + if (typeof agentTimestampMs === "number" && Number.isFinite(agentTimestampMs)) { + timestampMs = agentTimestampMs; + } + } + if (timestampMs === null) { + const timestamp = record["timestamp"]; + if (typeof timestamp === "number" && Number.isFinite(timestamp)) { + timestampMs = timestamp > 1e12 ? timestamp : timestamp * 1000; + } + } + if (timestampMs === null) return []; + + const topLevel = readGrokUsageTotals(usageRecord); + if (topLevel === null) return []; + + const modelUsage = usageRecord["modelUsage"]; + const modelEntries: Array<{ model: string; totals: GrokUsageTotals }> = []; + if (typeof modelUsage === "object" && modelUsage !== null) { + for (const [model, raw] of Object.entries(modelUsage as Record)) { + if (model.length === 0) continue; + const totals = readGrokUsageTotals(raw); + if (totals === null) continue; + modelEntries.push({ model, totals }); + } + } + + if (modelEntries.length === 0) { + if (totalTokens(grokTotalsToUsage(topLevel)) === 0) return []; + return [ + { + provider: "grok", + timestampMs, + model: "grok", + sessionId, + totals: grokTotalsToUsage(topLevel), + reportedCostUsd: grokCostTicksToUsd(topLevel.costUsdTicks), + // No prompt id means we cannot tell two same-second updates apart. + dedupeKey: promptId === null ? null : `${sessionId}:${promptId}:grok`, + }, + ]; + } + + // Cost allocation: + // 1. Emitted models with their own costUsdTicks keep those values. + // 2. Remaining aggregate cost (top-level minus those per-model ticks, + // clamped at 0) is pro-rated across emitted models that lack ticks, + // by token share among the unticked models only. + // 3. When no model has per-model ticks, remaining equals the full + // aggregate and every emitted model gets a token-share slice. + // Zero-token rows are never emitted and never count toward used ticks. + const topLevelCostUsd = grokCostTicksToUsd(topLevel.costUsdTicks); + let usedTickedCostUsd = 0; + let untickedTokenDenominator = 0; + for (const entry of modelEntries) { + const tokens = totalTokens(grokTotalsToUsage(entry.totals)); + if (tokens === 0) continue; + if (entry.totals.costUsdTicks !== null) { + usedTickedCostUsd += grokCostTicksToUsd(entry.totals.costUsdTicks) ?? 0; + } else { + untickedTokenDenominator += tokens; + } + } + const remainingCostUsd = + topLevelCostUsd === null ? null : Math.max(0, topLevelCostUsd - usedTickedCostUsd); + + const results: UsageRecord[] = []; + for (const entry of modelEntries) { + const totals = grokTotalsToUsage(entry.totals); + if (totalTokens(totals) === 0) continue; + + let reportedCostUsd = grokCostTicksToUsd(entry.totals.costUsdTicks); + if (reportedCostUsd === null && remainingCostUsd !== null && untickedTokenDenominator > 0) { + reportedCostUsd = remainingCostUsd * (totalTokens(totals) / untickedTokenDenominator); + } + + results.push({ + provider: "grok", + timestampMs, + model: entry.model, + sessionId, + totals, + reportedCostUsd, + dedupeKey: promptId === null ? null : `${sessionId}:${promptId}:${entry.model}`, + }); + } + return results; +} + export { EMPTY_TOTALS }; diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index 6cf4400c62eb..9cff53451039 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -30,7 +30,7 @@ import { type VcsStatusInput, type VcsStatusResult, } from "@t3tools/contracts"; -import { makeGitVcsDriverCore } from "./GitVcsDriverCore.ts"; +import { makeGitVcsDriverCore, splitNullSeparatedGitStdoutPaths } from "./GitVcsDriverCore.ts"; import * as VcsDriver from "./VcsDriver.ts"; import * as VcsProcess from "./VcsProcess.ts"; @@ -347,17 +347,6 @@ const nowFreshness = Effect.fn("GitVcsDriver.nowFreshness")(function* () { }; }); -function splitNullSeparatedPaths(input: string, truncated: boolean): string[] { - const parts = input.split("\0"); - if (parts.length === 0) return []; - - if (truncated && parts[parts.length - 1]?.length) { - parts.pop(); - } - - return parts.filter((value) => value.length > 0); -} - function chunkPathsForGitCheckIgnore(relativePaths: ReadonlyArray): string[][] { const chunks: string[][] = []; let chunk: string[] = []; @@ -541,7 +530,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( ? Effect.gen(function* () { const freshness = yield* nowFreshness(); return { - paths: splitNullSeparatedPaths(result.stdout, result.stdoutTruncated), + paths: splitNullSeparatedGitStdoutPaths(result), truncated: result.stdoutTruncated, freshness, }; @@ -639,7 +628,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( }); } - for (const ignoredPath of splitNullSeparatedPaths(result.stdout, result.stdoutTruncated)) { + for (const ignoredPath of splitNullSeparatedGitStdoutPaths(result)) { ignoredPaths.add(ignoredPath); } } diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index cff5d6d47f6b..587a3e4abbde 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -759,18 +759,6 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { assert.notInclude(error.detail, "Git command failed in"); }), ); - - it.effect("treats removing an already-gone worktree as a no-op", () => - Effect.gen(function* () { - const cwd = yield* makeTmpDir(); - const pathService = yield* Path.Path; - const missingWorktree = pathService.join(cwd, "missing-worktree"); - const driver = yield* GitVcsDriver.GitVcsDriver; - yield* driver.initRepo({ cwd }); - - yield* driver.removeWorktree({ cwd, path: missingWorktree }); - }), - ); }); describe("review diff previews", () => { @@ -1837,6 +1825,111 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }), ); + it.effect("publishes a branch tracking its base under its own name, not the base", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const remote = yield* makeTmpDir("git-remote-"); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* git(cwd, ["branch", "-M", "main"]); + yield* git(remote, ["init", "--bare"]); + yield* git(cwd, ["remote", "add", "origin", remote]); + yield* git(cwd, ["push", "-u", "origin", "main"]); + yield* git(cwd, ["checkout", "-b", "dev"]); + yield* git(cwd, ["push", "-u", "origin", "dev"]); + const devSha = yield* git(cwd, ["rev-parse", "HEAD"]); + yield* git(cwd, ["checkout", "-b", "feature/x", "origin/dev"]); + yield* writeTextFile(cwd, "feature.txt", "feature\n"); + yield* driver.prepareCommitContext(cwd); + yield* driver.commit(cwd, "Add feature", ""); + + const pushed = yield* driver.pushCurrentBranch(cwd, null); + + assert.deepInclude(pushed, { + status: "pushed", + branch: "feature/x", + upstreamBranch: "origin/feature/x", + setUpstream: true, + }); + assert.equal(yield* git(remote, ["log", "-1", "--pretty=%s", "feature/x"]), "Add feature"); + assert.equal(yield* git(remote, ["rev-parse", "dev"]), devSha); + assert.equal( + yield* git(cwd, ["rev-parse", "--abbrev-ref", "@{upstream}"]), + "origin/feature/x", + ); + assert.equal(yield* driver.readConfigValue(cwd, "branch.feature/x.gh-merge-base"), "dev"); + }), + ); + + it.effect("keeps a recorded merge base when publishing a tracked branch", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const remote = yield* makeTmpDir("git-remote-"); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* git(cwd, ["branch", "-M", "main"]); + yield* git(remote, ["init", "--bare"]); + yield* git(cwd, ["remote", "add", "origin", remote]); + yield* git(cwd, ["push", "-u", "origin", "main"]); + yield* git(cwd, ["checkout", "-b", "feature/y", "origin/main"]); + yield* git(cwd, ["config", "branch.feature/y.gh-merge-base", "release/v2"]); + yield* writeTextFile(cwd, "feature.txt", "feature\n"); + yield* driver.prepareCommitContext(cwd); + yield* driver.commit(cwd, "Add feature", ""); + + const pushed = yield* driver.pushCurrentBranch(cwd, null); + + assert.deepInclude(pushed, { + status: "pushed", + branch: "feature/y", + upstreamBranch: "origin/feature/y", + setUpstream: true, + }); + assert.equal( + yield* driver.readConfigValue(cwd, "branch.feature/y.gh-merge-base"), + "release/v2", + ); + }), + ); + + it.effect("still pushes a git-mangled tracking alias to its upstream head", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const remote = yield* makeTmpDir("git-remote-"); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* git(cwd, ["branch", "-M", "main"]); + yield* git(remote, ["init", "--bare"]); + yield* git(cwd, ["remote", "add", "my-org/upstream", remote]); + yield* git(cwd, ["push", "my-org/upstream", "main:effect-atom"]); + yield* git(cwd, ["fetch", "my-org/upstream"]); + // `checkout --track my-org/upstream/effect-atom` cannot name the local + // branch `effect-atom`, so git keeps `upstream/effect-atom`. Its + // upstream is still its published head. + yield* git(cwd, ["checkout", "--track", "my-org/upstream/effect-atom"]); + assert.equal( + yield* git(cwd, ["rev-parse", "--abbrev-ref", "HEAD"]), + "upstream/effect-atom", + ); + yield* writeTextFile(cwd, "alias.txt", "alias\n"); + yield* driver.prepareCommitContext(cwd); + yield* driver.commit(cwd, "Add alias update", ""); + + const pushed = yield* driver.pushCurrentBranch(cwd, null); + + assert.deepInclude(pushed, { + status: "pushed", + branch: "upstream/effect-atom", + upstreamBranch: "my-org/upstream/effect-atom", + setUpstream: false, + }); + assert.equal( + yield* git(remote, ["log", "-1", "--pretty=%s", "effect-atom"]), + "Add alias update", + ); + }), + ); + it.effect("pushes to the requested remote instead of the primary remote", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 19e58fea63df..ef2d00291caf 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -475,6 +475,7 @@ function trace2ChildKey(record: Record): string | null { } const Trace2Record = Schema.Record(Schema.String, Schema.Unknown); +const decodeTrace2Record = decodeJsonResult(Trace2Record); const createTrace2Monitor = Effect.fn("createTrace2Monitor")(function* ( input: Pick, @@ -509,7 +510,7 @@ const createTrace2Monitor = Effect.fn("createTrace2Monitor")(function* ( return; } - const traceRecord = decodeJsonResult(Trace2Record)(trimmedLine); + const traceRecord = decodeTrace2Record(trimmedLine); if (Result.isFailure(traceRecord)) { yield* Effect.logDebug( `GitVcsDriver.trace2: failed to parse trace line for ${input.operation} in ${input.cwd} (${input.args.length} arguments)`, @@ -2007,6 +2008,55 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* Effect.orElseSucceed(() => null), ); if (currentUpstream) { + // A branch tracking a differently named ref was cut from it, the way + // `git checkout -b feature origin/dev` and our own worktree flow leave + // it. That upstream is the branch's base, not its publish target, and + // pushing HEAD onto it would write feature commits to a shared branch + // (bare `git push` refuses this under push.default=simple). The one + // same-repo tracking setup that legitimately differs is a git-mangled + // alias such as local `upstream/effect-atom` for my-org/upstream's + // `effect-atom`: the branch name ends in the upstream head while the + // upstream ref ends in the branch name. + const isAliasOfUpstreamHead = + branch === currentUpstream.branchName || + (branch.endsWith(`/${currentUpstream.branchName}`) && + currentUpstream.upstreamRef.endsWith(`/${branch}`)); + if (!isAliasOfUpstreamHead) { + const publishRemoteName = yield* resolvePushRemoteName(cwd, branch).pipe( + Effect.orElseSucceed(() => null), + ); + const remoteName = publishRemoteName ?? currentUpstream.remoteName; + const publishBranch = yield* resolvePublishBranchName(cwd, branch); + // `-u` retargets the upstream to the published branch, so keep the + // base recorded first; base resolution reads gh-merge-base before the + // upstream ref. + const configuredMergeBase = yield* runGitStdout( + "GitVcsDriver.pushCurrentBranch.readMergeBase", + cwd, + ["config", "--get", `branch.${branch}.gh-merge-base`], + true, + ).pipe(Effect.map((stdout) => stdout.trim())); + if (configuredMergeBase.length === 0) { + yield* runGit("GitVcsDriver.pushCurrentBranch.recordMergeBase", cwd, [ + "config", + `branch.${branch}.gh-merge-base`, + currentUpstream.branchName, + ]); + } + yield* runGit( + "GitVcsDriver.pushCurrentBranch.pushOwnBranch", + cwd, + ["push", "-u", remoteName, `HEAD:refs/heads/${publishBranch}`], + { timeoutMs: null }, + ); + return { + status: "pushed" as const, + branch, + upstreamBranch: `${remoteName}/${publishBranch}`, + setUpstream: true, + }; + } + yield* runGit( "GitVcsDriver.pushCurrentBranch.pushUpstream", cwd, diff --git a/apps/server/src/workspace/WorkspaceEntries.ts b/apps/server/src/workspace/WorkspaceEntries.ts index 28a30481b1b6..2cf45a1a9cd8 100644 --- a/apps/server/src/workspace/WorkspaceEntries.ts +++ b/apps/server/src/workspace/WorkspaceEntries.ts @@ -1,6 +1,5 @@ // @effect-diagnostics nodeBuiltinImport:off import * as NodeFSP from "node:fs/promises"; -import * as NodeOS from "node:os"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; @@ -23,6 +22,7 @@ import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { isExplicitRelativePath, isWindowsAbsolutePath } from "@t3tools/shared/path"; import { normalizeSearchQuery } from "@t3tools/shared/searchRanking"; +import { expandHomePathWith } from "../pathExpansion.ts"; import * as WorkspacePaths from "./WorkspacePaths.ts"; import * as WorkspaceSearchIndex from "./WorkspaceSearchIndex.ts"; @@ -103,16 +103,6 @@ export class WorkspaceEntries extends Context.Service< } >()("t3/workspace/WorkspaceEntries") {} -function expandHomePath(input: string, path: Path.Path): string { - if (input === "~") { - return NodeOS.homedir(); - } - if (input.startsWith("~/") || input.startsWith("~\\")) { - return path.join(NodeOS.homedir(), input.slice(2)); - } - return input; -} - const resolveBrowseTarget = Effect.fn("WorkspaceEntries.resolveBrowseTarget")(function* ( input: FilesystemBrowseInput, path: Path.Path, @@ -127,7 +117,7 @@ const resolveBrowseTarget = Effect.fn("WorkspaceEntries.resolveBrowseTarget")(fu } if (!isExplicitRelativePath(input.partialPath)) { - return path.resolve(expandHomePath(input.partialPath, path)); + return path.resolve(expandHomePathWith(input.partialPath, path)); } if (!input.cwd) { @@ -135,7 +125,7 @@ const resolveBrowseTarget = Effect.fn("WorkspaceEntries.resolveBrowseTarget")(fu partialPath: input.partialPath, }); } - return path.resolve(expandHomePath(input.cwd, path), input.partialPath); + return path.resolve(expandHomePathWith(input.cwd, path), input.partialPath); }); export const make = Effect.gen(function* () { diff --git a/apps/server/src/workspace/WorkspacePaths.ts b/apps/server/src/workspace/WorkspacePaths.ts index 5acf6677cdef..d9eb4cdf2744 100644 --- a/apps/server/src/workspace/WorkspacePaths.ts +++ b/apps/server/src/workspace/WorkspacePaths.ts @@ -6,7 +6,6 @@ * * @module WorkspacePaths */ -import * as NodeOS from "node:os"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; @@ -15,6 +14,8 @@ import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; +import { expandHomePathWith } from "../pathExpansion.ts"; + export class WorkspaceRootNotExistsError extends Schema.TaggedErrorClass()( "WorkspaceRootNotExistsError", { @@ -121,16 +122,6 @@ function toPosixRelativePath(input: string): string { return input.replaceAll("\\", "/"); } -function expandHomePath(input: string, path: Path.Path): string { - if (input === "~") { - return NodeOS.homedir(); - } - if (input.startsWith("~/") || input.startsWith("~\\")) { - return path.join(NodeOS.homedir(), input.slice(2)); - } - return input; -} - export const make = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -161,7 +152,7 @@ export const make = Effect.gen(function* () { const normalizeWorkspaceRoot: WorkspacePaths["Service"]["normalizeWorkspaceRoot"] = Effect.fn( "WorkspacePaths.normalizeWorkspaceRoot", )(function* (workspaceRoot, options) { - const normalizedWorkspaceRoot = path.resolve(expandHomePath(workspaceRoot.trim(), path)); + const normalizedWorkspaceRoot = path.resolve(expandHomePathWith(workspaceRoot.trim(), path)); let workspaceStat = yield* statWorkspaceRoot( workspaceRoot, normalizedWorkspaceRoot, diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 55b0be07c667..db3b74b7e4b6 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -15,10 +15,16 @@ import { type AuthAccessStreamEvent, type AuthEnvironmentScope, AuthSessionId, + ClientConnectionMethod, + ClientDeviceType, + ClientOs, ClientSurface, + ClientWebDeployment, CommandId, type DiscoveredLocalServerList, EventId, + type EditorId, + type FileManagerRevealKind, type OrchestrationClientOrigin, type OrchestrationCommand, type GitActionProgressEvent, @@ -67,18 +73,21 @@ import { RpcSerialization, RpcServer } from "effect/unstable/rpc"; import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; import * as ServerConfig from "./config.ts"; +import * as EnvironmentTheme from "./environmentTheme.ts"; import * as Keybindings from "./keybindings.ts"; import * as ExternalLauncher from "./process/externalLauncher.ts"; import { projectActivityEvent, projectThreadDetailSnapshot, } from "./orchestration/ActivityPayloadProjection.ts"; +import { makeThreadLiveEventCoalescer } from "./orchestration/ThreadLiveEventCoalescer.ts"; import { cleanupFailedUploadedAttachments, normalizeDispatchCommand, } from "./orchestration/Normalizer.ts"; import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts"; import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts"; +import { ThreadDeletionReactor } from "./orchestration/Services/ThreadDeletionReactor.ts"; import { observeRpcEffect as instrumentRpcEffect, observeRpcStream as instrumentRpcStream, @@ -136,16 +145,25 @@ import * as RelayClient from "@t3tools/shared/relayClient"; const isOrchestrationDispatchCommandError = Schema.is(OrchestrationDispatchCommandError); const nowIso = Effect.map(DateTime.now, DateTime.formatIso); -const EDITOR_DISCOVERY_TIMEOUT = Duration.seconds(5); +const CONFIG_DISCOVERY_TIMEOUT = Duration.seconds(5); -export const resolveAvailableEditorsForConfig = ( - discovery: Effect.Effect, E, R>, +const resolveDiscoveryForConfig = ( + discovery: Effect.Effect, + onTimeout: () => A, ) => discovery.pipe( - Effect.timeoutOption(EDITOR_DISCOVERY_TIMEOUT), - Effect.map(Option.getOrElse(() => [])), + Effect.timeoutOption(CONFIG_DISCOVERY_TIMEOUT), + Effect.map(Option.getOrElse(onTimeout)), ); +export const resolveAvailableEditorsForConfig = ( + discovery: Effect.Effect, E, R>, +) => resolveDiscoveryForConfig(discovery, () => []); + +export const resolveFileManagerRevealKindForConfig = ( + discovery: Effect.Effect, +) => resolveDiscoveryForConfig(discovery, () => undefined); + function unexpectedCompatibilityError(error: never): never { throw new Error(`Unhandled compatibility error: ${String(error)}`); } @@ -359,7 +377,13 @@ function toAuthAccessStreamEvent( } const isClientSurface = Schema.is(ClientSurface); +const isClientConnectionMethod = Schema.is(ClientConnectionMethod); +const isClientDeviceType = Schema.is(ClientDeviceType); +const isClientOs = Schema.is(ClientOs); +const isClientWebDeployment = Schema.is(ClientWebDeployment); const MAX_CLIENT_APP_VERSION_LENGTH = 64; +const MAX_CLIENT_BROWSER_LENGTH = 64; +const MAX_CLIENT_DEVICE_MODEL_LENGTH = 80; // Optional client identity announced on the /ws upgrade URL next to wsTicket. // Lenient by design: absent or malformed values degrade to {} so a connection @@ -381,14 +405,56 @@ function readClientConnectionOrigin( }; } -const clientOriginAnalyticsProps = (origin: OrchestrationClientOrigin) => ({ - ...(origin.surface !== undefined ? { surface: origin.surface } : {}), - ...(origin.appVersion !== undefined ? { appVersion: origin.appVersion } : {}), -}); +// Client telemetry stays in this socket's RPC layer. It must not become a +// server-global "current client" because several client types can connect at once. +function readClientAnalyticsProps(request: HttpServerRequest.HttpServerRequest) { + const url = HttpServerRequest.toURL(request); + if (Option.isNone(url)) { + return {}; + } + + const surface = url.value.searchParams.get("clientSurface"); + const appVersion = url.value.searchParams.get("clientAppVersion")?.trim() ?? ""; + const deviceType = url.value.searchParams.get("clientDeviceType"); + const os = url.value.searchParams.get("clientOs"); + const webDeployment = url.value.searchParams.get("clientWebDeployment"); + const browser = url.value.searchParams.get("clientBrowser")?.trim() ?? ""; + const connectionMethod = url.value.searchParams.get("connectionMethod"); + const rawOsMajorVersion = url.value.searchParams.get("clientOsMajorVersion") ?? ""; + const osMajorVersion = Number(rawOsMajorVersion); + const deviceModel = url.value.searchParams.get("clientDeviceModel")?.trim() ?? ""; + const isMobile = surface === "mobile"; + const hasOsMajorVersion = + isMobile && rawOsMajorVersion !== "" && Number.isInteger(osMajorVersion) && osMajorVersion > 0; + const hasDeviceModel = + isMobile && deviceModel !== "" && deviceModel.length <= MAX_CLIENT_DEVICE_MODEL_LENGTH; + + return { + ...(isClientSurface(surface) ? { surface } : {}), + ...(appVersion !== "" && appVersion.length <= MAX_CLIENT_APP_VERSION_LENGTH + ? { appVersion, clientAppVersion: appVersion } + : {}), + ...(isClientOs(os) + ? { + clientOs: os, + ...(isMobile && (os === "iOS" || os === "Android") ? { os } : {}), + } + : {}), + ...(isClientDeviceType(deviceType) ? { clientDeviceType: deviceType } : {}), + ...(surface === "web" && isClientWebDeployment(webDeployment) ? { webDeployment } : {}), + ...(surface === "web" && browser !== "" && browser.length <= MAX_CLIENT_BROWSER_LENGTH + ? { clientBrowser: browser } + : {}), + ...(hasOsMajorVersion ? { osMajorVersion, clientOsMajorVersion: osMajorVersion } : {}), + ...(hasDeviceModel ? { deviceModel, clientDeviceModel: deviceModel } : {}), + ...(isClientConnectionMethod(connectionMethod) ? { connectionMethod } : {}), + }; +} const makeWsRpcLayer = ( currentSession: EnvironmentAuth.AuthenticatedSession, clientOrigin: OrchestrationClientOrigin, + clientAnalyticsProps: Readonly>, previewAutomationBroker: PreviewAutomationBroker.PreviewAutomationBroker["Service"], ) => WsRpcGroup.toLayer( @@ -397,6 +463,7 @@ const makeWsRpcLayer = ( const crypto = yield* Crypto.Crypto; const projectionSnapshotQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; const orchestrationEngine = yield* OrchestrationEngine.OrchestrationEngineService; + const threadDeletionReactor = yield* ThreadDeletionReactor; const analytics = yield* AnalyticsService.AnalyticsService; // Every command dispatched on this connection carries the connecting // client's origin, including server-generated bootstrap sub-commands: @@ -410,24 +477,24 @@ const makeWsRpcLayer = ( command, hasClientOrigin ? { origin: clientOrigin } : undefined, ); - const originProps = clientOriginAnalyticsProps(clientOrigin); const recordClientCommandAnalytics = (command: OrchestrationCommand) => { switch (command.type) { case "thread.create": - return analytics.record("client.thread.started", originProps); + return analytics.record("client.thread.started", clientAnalyticsProps); case "thread.turn.start": return command.bootstrap?.createThread ? Effect.andThen( - analytics.record("client.thread.started", originProps), - analytics.record("client.turn.requested", originProps), + analytics.record("client.thread.started", clientAnalyticsProps), + analytics.record("client.turn.requested", clientAnalyticsProps), ) - : analytics.record("client.turn.requested", originProps); + : analytics.record("client.turn.requested", clientAnalyticsProps); default: return Effect.void; } }; const checkpointDiffQuery = yield* CheckpointDiffQuery.CheckpointDiffQuery; const keybindings = yield* Keybindings.Keybindings; + const environmentTheme = yield* EnvironmentTheme.EnvironmentThemeService; const externalLauncher = yield* ExternalLauncher.ExternalLauncher; const remoteOpenTargets = yield* RemoteOpenTargets.RemoteOpenTargets; const gitWorkflow = yield* GitWorkflowService.GitWorkflowService; @@ -964,7 +1031,7 @@ const makeWsRpcLayer = ( const bootstrapProgram = Effect.gen(function* () { if (bootstrap?.createThread) { - yield* dispatchFromClient({ + const created = yield* dispatchFromClient({ type: "thread.create", commandId: yield* serverCommandId("bootstrap-thread-create"), threadId: command.threadId, @@ -977,6 +1044,11 @@ const makeWsRpcLayer = ( worktreePath: bootstrap.createThread.worktreePath, createdAt: bootstrap.createThread.createdAt, }); + // The successful create is a fence in the engine command queue: + // every delete for the prior incarnation committed before it. + // Drain through that event before setup or turn start can own + // terminals and provider sessions under the reused thread id. + yield* threadDeletionReactor.drainThrough(created.sequence); createdThread = true; } @@ -1064,6 +1136,14 @@ const makeWsRpcLayer = ( normalizedCommand.type === "thread.turn.start" && normalizedCommand.bootstrap ? dispatchBootstrapTurnStart(normalizedCommand) : dispatchFromClient(normalizedCommand).pipe( + Effect.tap(({ sequence }) => + // Returning from thread.create is the handoff point at which + // clients may start resources for the new incarnation. Use + // its event sequence as the exact deletion-cleanup fence. + normalizedCommand.type === "thread.create" + ? threadDeletionReactor.drainThrough(sequence) + : Effect.void, + ), Effect.mapError((cause) => toDispatchCommandError(cause, "Failed to dispatch orchestration command"), ), @@ -1086,6 +1166,14 @@ const makeWsRpcLayer = ( ); const environment = yield* serverEnvironment.getDescriptor; const auth = yield* serverAuth.getDescriptor(); + const availableEditors: ReadonlyArray = yield* resolveAvailableEditorsForConfig( + externalLauncher.resolveAvailableEditors(), + ); + const fileManagerRevealKind = availableEditors.includes("file-manager") + ? yield* resolveFileManagerRevealKindForConfig( + externalLauncher.resolveFileManagerRevealKind(), + ) + : undefined; return { environment, @@ -1095,9 +1183,7 @@ const makeWsRpcLayer = ( keybindings: keybindingsConfig.keybindings, issues: keybindingsConfig.issues, providers, - availableEditors: yield* resolveAvailableEditorsForConfig( - externalLauncher.resolveAvailableEditors(), - ), + availableEditors, // Same discovery-with-timeout treatment as editors: a slow probe // must not stall server.getConfig, so it degrades to no targets. remoteOpenTargets: yield* resolveAvailableEditorsForConfig( @@ -1115,6 +1201,12 @@ const makeWsRpcLayer = ( }, settings, shellResumeCompletionMarker: true, + ...(fileManagerRevealKind === undefined + ? {} + : { + shellRevealInFileManager: true, + shellRevealInFileManagerKind: fileManagerRevealKind, + }), threadResumeCompletionMarker: true, threadSnapshotPagination: true, }; @@ -1131,23 +1223,17 @@ const makeWsRpcLayer = ( ORCHESTRATION_WS_METHODS.dispatchCommand, Effect.gen(function* () { const normalizedCommand = yield* normalizeDispatchCommand(command); - // Archive and settle both mean "done with this thread", so a - // live provider session must not keep running background work - // (PR monitors, dev servers, subagent fleets) after either - // lands. The decider rejects settling a starting/running - // session, so for settle this only ever stops an idle one; a - // stopped session-set does not count as activity, so the stop - // cannot un-settle the thread it follows. - const parkingCommand = - normalizedCommand.type === "thread.archive" || - normalizedCommand.type === "thread.settle" - ? normalizedCommand - : undefined; - // Best-effort on purpose: the user's archive/settle must not + // Archive removes the thread from the client, so this transport + // closes its session and terminals after the command lands. + // Settlement cleanup is driven by thread.settled events in the + // provider reactor, including settlements that have no client. + const archiveCommand = + normalizedCommand.type === "thread.archive" ? normalizedCommand : undefined; + // Best-effort on purpose: the user's archive must not // fail because this cleanup read blipped, so a failed read // logs and skips the stop instead of propagating. - const shouldStopSessionAfterCommand = parkingCommand - ? yield* projectionSnapshotQuery.getThreadShellById(parkingCommand.threadId).pipe( + const shouldStopSessionAfterCommand = archiveCommand + ? yield* projectionSnapshotQuery.getThreadShellById(archiveCommand.threadId).pipe( Effect.map( Option.match({ onNone: () => false, @@ -1158,7 +1244,7 @@ const makeWsRpcLayer = ( Effect.catchCause((cause) => Effect.logWarning( "failed to read thread session state before session-stop check", - { threadId: parkingCommand.threadId, cause }, + { threadId: archiveCommand.threadId, cause }, ).pipe(Effect.as(false)), ), ) @@ -1167,50 +1253,39 @@ const makeWsRpcLayer = ( Effect.tapError(() => cleanupFailedUploadedAttachments(command, normalizedCommand)), ); yield* recordClientCommandAnalytics(normalizedCommand); - if (parkingCommand) { - const parkingKind = parkingCommand.type === "thread.archive" ? "archive" : "settle"; + if (archiveCommand) { if (shouldStopSessionAfterCommand) { yield* Effect.gen(function* () { const stopCommand = yield* normalizeDispatchCommand({ type: "thread.session.stop", commandId: CommandId.make( - `session-stop-for-${parkingKind}:${parkingCommand.commandId}`, + `session-stop-for-archive:${archiveCommand.commandId}`, ), - threadId: parkingCommand.threadId, + threadId: archiveCommand.threadId, createdAt: yield* nowIso, - // A settled thread can be re-engaged before this stop is - // decided; the decider then drops the stop instead of - // killing the new session. Archive stops stay - // unconditional: turn starts on archived threads are - // rejected, so there is no new session to protect. - ...(parkingKind === "settle" ? { onlyIfSettled: true } : {}), }); yield* dispatchNormalizedCommand(stopCommand); }).pipe( Effect.catchCause((cause) => - Effect.logWarning(`failed to stop provider session during ${parkingKind}`, { - threadId: parkingCommand.threadId, + Effect.logWarning("failed to stop provider session during archive", { + threadId: archiveCommand.threadId, cause, }), ), ); } - // Terminals are user-opened panes, not thread background - // work: archive removes the thread from view so they close - // with it, but a settled thread stays reachable and may be - // un-settled, so its terminals stay up. - if (parkingCommand.type === "thread.archive") { - yield* terminalManager.close({ threadId: parkingCommand.threadId }).pipe( - Effect.catch((error) => - Effect.logWarning("failed to close thread terminals after archive", { - threadId: parkingCommand.threadId, - error: error.message, - }), - ), - ); - } + // Archive removes the thread from view, so its user-opened + // terminal panes close with it. + yield* terminalManager.close({ threadId: archiveCommand.threadId }).pipe( + Effect.catch((error) => + Effect.logWarning("failed to close thread terminals after archive", { + threadId: archiveCommand.threadId, + error: error.message, + }), + ), + ); } return result; }).pipe( @@ -1410,17 +1485,15 @@ const makeWsRpcLayer = ( Stream.filter(isThisThreadDetailEvent), Stream.map((event) => ({ kind: "event" as const, - event: projectActivityEvent(event), + event, })), ); // Attach live delivery before reading either replay or snapshot state. // Otherwise an event published while the snapshot is loading is lost. - const liveBuffer = yield* Queue.unbounded(); - yield* Effect.forkScoped( - liveStream.pipe(Stream.runForEach((item) => Queue.offer(liveBuffer, item))), - ); - const bufferedLiveStream = Stream.fromQueue(liveBuffer); + const liveBuffer = yield* makeThreadLiveEventCoalescer(); + yield* Effect.forkScoped(liveStream.pipe(Stream.runForEach(liveBuffer.offer))); + const bufferedLiveStream = liveBuffer.stream; // When the client already loaded the snapshot over HTTP it passes // that snapshot's sequence, and we resume the live subscription by @@ -1469,8 +1542,10 @@ const makeWsRpcLayer = ( input.requestCompletionMarker === true ? Stream.concat( Stream.fromEffect( - Queue.offer(liveBuffer, { kind: "synchronized" as const }), - ).pipe(Stream.drain), + liveBuffer + .offerAndWait({ kind: "synchronized" as const }) + .pipe(Effect.andThen(liveBuffer.takeAll)), + ).pipe(Stream.flatMap((items) => Stream.fromIterable(items))), bufferedLiveStream, ) : bufferedLiveStream; @@ -1511,8 +1586,10 @@ const makeWsRpcLayer = ( input.requestCompletionMarker === true ? Stream.concat( Stream.fromEffect( - Queue.offer(liveBuffer, { kind: "synchronized" as const }), - ).pipe(Stream.drain), + liveBuffer + .offerAndWait({ kind: "synchronized" as const }) + .pipe(Effect.andThen(liveBuffer.takeAll)), + ).pipe(Stream.flatMap((items) => Stream.fromIterable(items))), bufferedLiveStream, ) : bufferedLiveStream; @@ -2289,7 +2366,7 @@ const makeWsRpcLayer = ( ), { "rpc.aggregate": "preview" }, ), - [WS_METHODS.subscribeServerConfig]: (_input) => + [WS_METHODS.subscribeServerConfig]: (input) => observeRpcStreamEffect( WS_METHODS.subscribeServerConfig, Effect.gen(function* () { @@ -2311,6 +2388,23 @@ const makeWsRpcLayer = ( })), Stream.debounce(Duration.millis(PROVIDER_STATUS_DEBOUNCE_MS)), ); + // The only source of published themes: the stream emits the + // current set before any change, so the snapshot carrying it too + // would just send every client the same array twice per connect. + // Gated on the subscriber's capability flag because an + // already-shipped client decodes this stream against the old + // event union and its whole config subscription dies on an + // unknown member. + const environmentThemeUpdates = + input.environmentThemes === true + ? environmentTheme.streamChanges.pipe( + Stream.map((themes) => ({ + version: 1 as const, + type: "environmentThemesUpdated" as const, + payload: { themes }, + })), + ) + : Stream.empty; const settingsUpdates = serverSettings.streamChanges.pipe( Stream.map((settings) => ServerSettings.redactServerSettingsForClient(settings)), Stream.map((settings) => ({ @@ -2326,7 +2420,10 @@ const makeWsRpcLayer = ( const liveUpdates = Stream.merge( keybindingsUpdates, - Stream.merge(providerStatuses, settingsUpdates), + Stream.merge( + providerStatuses, + Stream.merge(settingsUpdates, environmentThemeUpdates), + ), ); return Stream.concat( @@ -2426,20 +2523,29 @@ export const websocketRpcRouteLayer = Layer.unwrap( const analytics = yield* AnalyticsService.AnalyticsService; const session = yield* serverAuth.authenticateWebSocketUpgrade(request).pipe( Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) => - failEnvironmentAuthInvalid(EnvironmentAuth.serverAuthCredentialReason(error)), + failEnvironmentAuthInvalid( + EnvironmentAuth.serverAuthCredentialReason(error), + EnvironmentAuth.serverAuthDpopFailureReason(error), + ), ), Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => failEnvironmentInternal("internal_error", error), ), ); const clientOrigin = readClientConnectionOrigin(request); + const clientAnalyticsProps = readClientAnalyticsProps(request); yield* sessions.recordClientConnection(session.sessionId, clientOrigin); - yield* analytics.record("client.connected", clientOriginAnalyticsProps(clientOrigin)); + yield* analytics.record("client.connected", clientAnalyticsProps); const rpcWebSocketHttpEffect = yield* RpcServer.toHttpEffectWebsocket(WsRpcGroup, { disableTracing: true, }).pipe( Effect.provide( - makeWsRpcLayer(session, clientOrigin, previewAutomationBroker).pipe( + makeWsRpcLayer( + session, + clientOrigin, + clientAnalyticsProps, + previewAutomationBroker, + ).pipe( Layer.provideMerge(RpcSerialization.layerJson), Layer.provide(ProviderMaintenanceRunner.layer), Layer.provide(Layer.succeed(ServerSelfUpdate.ServerSelfUpdate, serverSelfUpdate)), diff --git a/apps/web/package.json b/apps/web/package.json index 598feaec0ce9..283024eca095 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/web", - "version": "0.0.33", + "version": "0.0.38", "private": true, "type": "module", "scripts": { @@ -34,6 +34,7 @@ "class-variance-authority": "^0.7.1", "culori": "^4.0.2", "effect": "catalog:", + "heic-to": "^1.5.2", "jose": "catalog:", "jsonc-parser": "3.3.1", "jszip": "3.10.1", @@ -64,7 +65,6 @@ "@vitejs/plugin-react": "^6.0.0", "babel-plugin-react-compiler": "1.0.0", "compression": "^1.8.1", - "msw": "2.12.11", "tailwindcss": "^4.0.0", "vite": "catalog:", "vite-plus": "catalog:" diff --git a/apps/web/public/mockServiceWorker.js b/apps/web/public/mockServiceWorker.js deleted file mode 100644 index 8fa9dca80ea9..000000000000 --- a/apps/web/public/mockServiceWorker.js +++ /dev/null @@ -1,349 +0,0 @@ -/* eslint-disable */ -/* tslint:disable */ - -/** - * Mock Service Worker. - * @see https://github.com/mswjs/msw - * - Please do NOT modify this file. - */ - -const PACKAGE_VERSION = '2.12.11' -const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82' -const IS_MOCKED_RESPONSE = Symbol('isMockedResponse') -const activeClientIds = new Set() - -addEventListener('install', function () { - self.skipWaiting() -}) - -addEventListener('activate', function (event) { - event.waitUntil(self.clients.claim()) -}) - -addEventListener('message', async function (event) { - const clientId = Reflect.get(event.source || {}, 'id') - - if (!clientId || !self.clients) { - return - } - - const client = await self.clients.get(clientId) - - if (!client) { - return - } - - const allClients = await self.clients.matchAll({ - type: 'window', - }) - - switch (event.data) { - case 'KEEPALIVE_REQUEST': { - sendToClient(client, { - type: 'KEEPALIVE_RESPONSE', - }) - break - } - - case 'INTEGRITY_CHECK_REQUEST': { - sendToClient(client, { - type: 'INTEGRITY_CHECK_RESPONSE', - payload: { - packageVersion: PACKAGE_VERSION, - checksum: INTEGRITY_CHECKSUM, - }, - }) - break - } - - case 'MOCK_ACTIVATE': { - activeClientIds.add(clientId) - - sendToClient(client, { - type: 'MOCKING_ENABLED', - payload: { - client: { - id: client.id, - frameType: client.frameType, - }, - }, - }) - break - } - - case 'CLIENT_CLOSED': { - activeClientIds.delete(clientId) - - const remainingClients = allClients.filter((client) => { - return client.id !== clientId - }) - - // Unregister itself when there are no more clients - if (remainingClients.length === 0) { - self.registration.unregister() - } - - break - } - } -}) - -addEventListener('fetch', function (event) { - const requestInterceptedAt = Date.now() - - // Bypass navigation requests. - if (event.request.mode === 'navigate') { - return - } - - // Opening the DevTools triggers the "only-if-cached" request - // that cannot be handled by the worker. Bypass such requests. - if ( - event.request.cache === 'only-if-cached' && - event.request.mode !== 'same-origin' - ) { - return - } - - // Bypass all requests when there are no active clients. - // Prevents the self-unregistered worked from handling requests - // after it's been terminated (still remains active until the next reload). - if (activeClientIds.size === 0) { - return - } - - const requestId = crypto.randomUUID() - event.respondWith(handleRequest(event, requestId, requestInterceptedAt)) -}) - -/** - * @param {FetchEvent} event - * @param {string} requestId - * @param {number} requestInterceptedAt - */ -async function handleRequest(event, requestId, requestInterceptedAt) { - const client = await resolveMainClient(event) - const requestCloneForEvents = event.request.clone() - const response = await getResponse( - event, - client, - requestId, - requestInterceptedAt, - ) - - // Send back the response clone for the "response:*" life-cycle events. - // Ensure MSW is active and ready to handle the message, otherwise - // this message will pend indefinitely. - if (client && activeClientIds.has(client.id)) { - const serializedRequest = await serializeRequest(requestCloneForEvents) - - // Clone the response so both the client and the library could consume it. - const responseClone = response.clone() - - sendToClient( - client, - { - type: 'RESPONSE', - payload: { - isMockedResponse: IS_MOCKED_RESPONSE in response, - request: { - id: requestId, - ...serializedRequest, - }, - response: { - type: responseClone.type, - status: responseClone.status, - statusText: responseClone.statusText, - headers: Object.fromEntries(responseClone.headers.entries()), - body: responseClone.body, - }, - }, - }, - responseClone.body ? [serializedRequest.body, responseClone.body] : [], - ) - } - - return response -} - -/** - * Resolve the main client for the given event. - * Client that issues a request doesn't necessarily equal the client - * that registered the worker. It's with the latter the worker should - * communicate with during the response resolving phase. - * @param {FetchEvent} event - * @returns {Promise} - */ -async function resolveMainClient(event) { - const client = await self.clients.get(event.clientId) - - if (activeClientIds.has(event.clientId)) { - return client - } - - if (client?.frameType === 'top-level') { - return client - } - - const allClients = await self.clients.matchAll({ - type: 'window', - }) - - return allClients - .filter((client) => { - // Get only those clients that are currently visible. - return client.visibilityState === 'visible' - }) - .find((client) => { - // Find the client ID that's recorded in the - // set of clients that have registered the worker. - return activeClientIds.has(client.id) - }) -} - -/** - * @param {FetchEvent} event - * @param {Client | undefined} client - * @param {string} requestId - * @param {number} requestInterceptedAt - * @returns {Promise} - */ -async function getResponse(event, client, requestId, requestInterceptedAt) { - // Clone the request because it might've been already used - // (i.e. its body has been read and sent to the client). - const requestClone = event.request.clone() - - function passthrough() { - // Cast the request headers to a new Headers instance - // so the headers can be manipulated with. - const headers = new Headers(requestClone.headers) - - // Remove the "accept" header value that marked this request as passthrough. - // This prevents request alteration and also keeps it compliant with the - // user-defined CORS policies. - const acceptHeader = headers.get('accept') - if (acceptHeader) { - const values = acceptHeader.split(',').map((value) => value.trim()) - const filteredValues = values.filter( - (value) => value !== 'msw/passthrough', - ) - - if (filteredValues.length > 0) { - headers.set('accept', filteredValues.join(', ')) - } else { - headers.delete('accept') - } - } - - return fetch(requestClone, { headers }) - } - - // Bypass mocking when the client is not active. - if (!client) { - return passthrough() - } - - // Bypass initial page load requests (i.e. static assets). - // The absence of the immediate/parent client in the map of the active clients - // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet - // and is not ready to handle requests. - if (!activeClientIds.has(client.id)) { - return passthrough() - } - - // Notify the client that a request has been intercepted. - const serializedRequest = await serializeRequest(event.request) - const clientMessage = await sendToClient( - client, - { - type: 'REQUEST', - payload: { - id: requestId, - interceptedAt: requestInterceptedAt, - ...serializedRequest, - }, - }, - [serializedRequest.body], - ) - - switch (clientMessage.type) { - case 'MOCK_RESPONSE': { - return respondWithMock(clientMessage.data) - } - - case 'PASSTHROUGH': { - return passthrough() - } - } - - return passthrough() -} - -/** - * @param {Client} client - * @param {any} message - * @param {Array} transferrables - * @returns {Promise} - */ -function sendToClient(client, message, transferrables = []) { - return new Promise((resolve, reject) => { - const channel = new MessageChannel() - - channel.port1.onmessage = (event) => { - if (event.data && event.data.error) { - return reject(event.data.error) - } - - resolve(event.data) - } - - client.postMessage(message, [ - channel.port2, - ...transferrables.filter(Boolean), - ]) - }) -} - -/** - * @param {Response} response - * @returns {Response} - */ -function respondWithMock(response) { - // Setting response status code to 0 is a no-op. - // However, when responding with a "Response.error()", the produced Response - // instance will have status code set to 0. Since it's not possible to create - // a Response instance with status code 0, handle that use-case separately. - if (response.status === 0) { - return Response.error() - } - - const mockedResponse = new Response(response.body, response) - - Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, { - value: true, - enumerable: true, - }) - - return mockedResponse -} - -/** - * @param {Request} request - */ -async function serializeRequest(request) { - return { - url: request.url, - mode: request.mode, - method: request.method, - headers: Object.fromEntries(request.headers.entries()), - cache: request.cache, - credentials: request.credentials, - destination: request.destination, - integrity: request.integrity, - redirect: request.redirect, - referrer: request.referrer, - referrerPolicy: request.referrerPolicy, - body: await request.arrayBuffer(), - keepalive: request.keepalive, - } -} diff --git a/apps/web/src/appearanceFonts.test.ts b/apps/web/src/appearanceFonts.test.ts index 31a2f1d779c5..0e3ebb208346 100644 --- a/apps/web/src/appearanceFonts.test.ts +++ b/apps/web/src/appearanceFonts.test.ts @@ -5,9 +5,6 @@ import { clampCodeFontSize, clampInterfaceFontSize, clampPromptFontSize, - DEFAULT_CODE_FONT_STACK, - DEFAULT_SANS_FONT_STACK, - appearanceFontStack, cssFontFamilies, resolveDefaultFamilyLabel, resolveTerminalFontPreference, @@ -58,18 +55,6 @@ describe("resolveDefaultFamilyLabel", () => { }); }); -describe("appearanceFontStack", () => { - it("prepends the custom family to the default stack", () => { - expect(appearanceFontStack("Fira Code", DEFAULT_CODE_FONT_STACK)).toBe( - `"Fira Code", ${DEFAULT_CODE_FONT_STACK}`, - ); - }); - - it("falls back to the default stack when unset", () => { - expect(appearanceFontStack("", DEFAULT_SANS_FONT_STACK)).toBe(DEFAULT_SANS_FONT_STACK); - }); -}); - describe("resolveTerminalFontPreference", () => { it("inherits the code font in simple mode", () => { expect( diff --git a/apps/web/src/appearanceFonts.ts b/apps/web/src/appearanceFonts.ts index 6053e5fb0dd4..74bb88a06c31 100644 --- a/apps/web/src/appearanceFonts.ts +++ b/apps/web/src/appearanceFonts.ts @@ -71,12 +71,6 @@ export function cssFontFamilies(input: string): string | null { return families.length > 0 ? families.join(", ") : null; } -/** The full stack a preference resolves to: custom families before the default. */ -export function appearanceFontStack(custom: string, defaultStack: string): string { - const families = cssFontFamilies(custom); - return families === null ? defaultStack : `${families}, ${defaultStack}`; -} - export interface AppearanceFontPreferences { readonly sans: string; readonly code: string; diff --git a/apps/web/src/assets/assetUrls.test.ts b/apps/web/src/assets/assetUrls.test.ts deleted file mode 100644 index e4634f5b98db..000000000000 --- a/apps/web/src/assets/assetUrls.test.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { resolveAssetUrl } from "./assetUrls"; - -describe("resolveAssetUrl", () => { - it("resolves an environment-relative asset URL", () => { - expect( - resolveAssetUrl("https://environment.example/base/", "/api/assets/signed-token/favicon.png"), - ).toBe("https://environment.example/api/assets/signed-token/favicon.png"); - }); - - it("rejects an invalid environment base URL", () => { - expect(resolveAssetUrl("not a URL", "/api/assets/signed-token/favicon.png")).toBeNull(); - }); -}); diff --git a/apps/web/src/assets/assetUrls.ts b/apps/web/src/assets/assetUrls.ts index f8c0b5ae75f7..5c642471404a 100644 --- a/apps/web/src/assets/assetUrls.ts +++ b/apps/web/src/assets/assetUrls.ts @@ -1,11 +1,13 @@ import { useAtomValue } from "@effect/atom-react"; import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; +import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import type { AssetResource, EnvironmentId } from "@t3tools/contracts"; import { AsyncResult } from "effect/unstable/reactivity"; -import { useMemo } from "react"; +import { useCallback, useMemo } from "react"; import { assetEnvironment } from "~/state/assets"; import { usePreparedConnection } from "~/state/session"; +import { useAtomQueryRunner } from "~/state/use-atom-query-runner"; export { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; @@ -49,6 +51,21 @@ export function useAssetUrl(environmentId: EnvironmentId, resource: AssetResourc return result.url; } +/** Re-mints an exact-file capability after a file change or an explicit retry. */ +export function useAssetUrlRefresh( + environmentId: EnvironmentId, + resource: AssetResource, +): () => Promise { + const refresh = useAtomQueryRunner(assetEnvironment.createUrl, { + reportFailure: false, + refresh: true, + }); + return useCallback(async () => { + const result = await refresh({ environmentId, input: { resource } }); + if (result._tag === "Failure") throw squashAtomCommandFailure(result); + }, [environmentId, resource, refresh]); +} + export function useAssetUrls( environmentId: EnvironmentId, resources: ReadonlyArray, diff --git a/apps/web/src/browser/ElectronBrowserHost.tsx b/apps/web/src/browser/ElectronBrowserHost.tsx index fbf7c14b738c..5425bca0b4bc 100644 --- a/apps/web/src/browser/ElectronBrowserHost.tsx +++ b/apps/web/src/browser/ElectronBrowserHost.tsx @@ -29,6 +29,8 @@ export function ElectronBrowserHost() { previewState.serverEpoch, snapshot.tabId, ), + pictureInPicture: + previewState.desktopByTabId[snapshot.tabId]?.pictureInPicture ?? false, zoomFactor: previewState.desktopByTabId[snapshot.tabId]?.zoomFactor ?? 1, })) : []; @@ -80,7 +82,7 @@ export function ElectronBrowserHost() { if (!isElectron) return null; return (
- {sessions.map(({ threadRef, snapshot, runtimeTabId, zoomFactor }) => { + {sessions.map(({ threadRef, snapshot, runtimeTabId, pictureInPicture, zoomFactor }) => { const url = snapshot.navStatus._tag === "Idle" ? null : snapshot.navStatus.url; return ( ); diff --git a/apps/web/src/browser/HostedBrowserWebview.tsx b/apps/web/src/browser/HostedBrowserWebview.tsx index ae0526abb15f..77c65264aa94 100644 --- a/apps/web/src/browser/HostedBrowserWebview.tsx +++ b/apps/web/src/browser/HostedBrowserWebview.tsx @@ -6,9 +6,10 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { previewBridge } from "~/components/preview/previewBridge"; import { usePreviewBridge } from "~/components/preview/usePreviewBridge"; -import { cn } from "~/lib/utils"; +import { cn, isMacPlatform } from "~/lib/utils"; import { resolveBrowserSurfacePanelRect, useBrowserSurfaceStore } from "./browserSurfaceStore"; +import { useActiveBrowserRecordingTabIds } from "./browserRecording"; import { browserViewportSettingKey, resolveBrowserViewportLayout, @@ -47,9 +48,11 @@ export function HostedBrowserWebview(props: { readonly runtimeTabId: string; readonly initialUrl: string | null; readonly viewport: PreviewViewportSetting; + readonly pictureInPicture: boolean; readonly zoomFactor: number; }) { - const { threadRef, tabId, runtimeTabId, initialUrl, viewport, zoomFactor } = props; + const { threadRef, tabId, runtimeTabId, initialUrl, viewport, pictureInPicture, zoomFactor } = + props; const config = usePreviewWebviewConfig(threadRef.environmentId); const [initialSrc] = useState(() => initialUrl ?? "about:blank"); const tabLeaseRef = useRef(null); @@ -70,6 +73,10 @@ export function HostedBrowserWebview(props: { }; }), ); + const backgroundActivity = useBrowserSurfaceStore( + (state) => (state.activityByTabId[runtimeTabId] ?? 0) > 0, + ); + const recordingActive = useActiveBrowserRecordingTabIds().has(runtimeTabId); usePreviewBridge({ threadRef, tabId, runtimeTabId }); useEffect(() => { @@ -92,7 +99,6 @@ export function HostedBrowserWebview(props: { const setWebviewRef = useCallback((node: HTMLElement | null) => { webviewRef.current = node as ElectronWebview | null; - if (node && !node.hasAttribute("allowpopups")) node.setAttribute("allowpopups", "true"); }, []); useEffect(() => { @@ -231,8 +237,14 @@ export function HostedBrowserWebview(props: { if (!config) return null; + const renderingActive = active || backgroundActivity || pictureInPicture || recordingActive; const wrapperStyle = resolveHostedBrowserWebviewWrapperStyle({ active, + renderingActive, + // Electron 43 can permanently blank a macOS webview after `visibility: hidden`. + // Inactive macOS guests intentionally remain paintable offscreen; other platforms still + // suspend them, and automation continues to see the macOS guests as inactive. + keepPaintableWhenInactive: isMacPlatform(navigator.platform), cornerRadius: presentation.cornerRadius, rect: lastRect, hiddenSize, @@ -244,6 +256,7 @@ export function HostedBrowserWebview(props: { className="fixed overflow-hidden bg-muted/35" style={{ ...wrapperStyle, overscrollBehavior: "contain" }} onScroll={syncContentPresentation} + data-preview-rendering={renderingActive ? "active" : "suspended"} data-preview-viewport={runtimeTabId} >
@@ -259,6 +272,12 @@ export function HostedBrowserWebview(props: { { const events: string[] = []; - type Frame = { - readonly tabId: string; - readonly data: string; - readonly width: number; - readonly height: number; - readonly receivedAt: string; - }; - const frameSubscription: { listener: ((frame: Frame) => void) | null } = { - listener: null, - }; - const surfaceState = { - byTabId: {} as Record, - }; return { + clientSettings: { browserRecordingFrameRate: 30 as 30 | 60 }, events, - frameSubscription, - onFrame: vi.fn((listener: (frame: Frame) => void) => { - frameSubscription.listener = listener; - return () => { - if (frameSubscription.listener === listener) frameSubscription.listener = null; - }; - }), + getDisplayMedia: vi.fn(), + requestDisplayMediaCapture: vi.fn((_tabId: string) => undefined), registrySet: vi.fn((_atom: unknown, value: { readonly tabIds: ReadonlySet }) => { events.push( value.tabIds.size === 0 ? "clear" : `publish:${Array.from(value.tabIds).join(",")}`, @@ -47,31 +34,24 @@ const { sizeBytes: 0, createdAt: "2026-06-26T00:00:00.000Z", })), - startScreencast: vi.fn(async (tabId: string) => { + startScreencast: vi.fn(async (_tabId: string) => { events.push("start-screencast"); - const surface = surfaceState.byTabId[tabId] as - | { - readonly content?: { readonly width: number; readonly height: number }; - readonly rect?: { readonly width: number; readonly height: number }; - } - | undefined; - const size = surface?.content ?? surface?.rect; - frameSubscription.listener?.({ - tabId, - data: "initial-frame", - width: size?.width ?? 1280, - height: size?.height ?? 800, - receivedAt: "2026-06-26T00:00:00.000Z", - }); }), stopScreencast: vi.fn(async () => undefined), - surfaceState, }; }); vi.mock("~/components/preview/previewBridge", () => ({ previewBridge: { - recording: { onFrame, save, startScreencast, stopScreencast }, + recording: { + onFrame: vi.fn(), + save, + startScreencast: async (tabId: string) => { + await startScreencast(tabId); + requestDisplayMediaCapture(tabId); + }, + stopScreencast, + }, }, })); @@ -79,32 +59,50 @@ vi.mock("~/rpc/atomRegistry", () => ({ appAtomRegistry: { set: registrySet }, })); -vi.mock("./browserSurfaceStore", () => ({ - useBrowserSurfaceStore: { - getState: () => surfaceState, - }, +vi.mock("~/hooks/useSettings", () => ({ + ensureClientSettingsHydrated: vi.fn(async () => undefined), + getClientSettings: () => clientSettings, })); import { - BROWSER_RECORDING_FIRST_FRAME_SIZE_TIMEOUT_MS, + BROWSER_RECORDING_PAINT_SETTLE_TIMEOUT_MS, BROWSER_RECORDING_STARTUP_SETTLE_TIMEOUT_MS, + BrowserRecordingCaptureTimeoutError, BrowserRecordingConflictError, + BrowserRecordingFormatUnavailableError, + BrowserRecordingStartCancelledError, findActiveBrowserRecordingRuntimeTabId, readActiveBrowserRecordingTabIds, readActiveBrowserRecordingTargets, startBrowserRecording, stopBrowserRecording, } from "./browserRecording"; +import { useBrowserSurfaceStore } from "./browserSurfaceStore"; import { previewRuntimeTabId } from "./previewRuntimeTabId"; class FakeMediaRecorder { - static isTypeSupported(): boolean { - return true; + static readonly instances: FakeMediaRecorder[] = []; + static supportedTypes = new Set(["video/webm;codecs=vp9"]); + static outputMimeType: string | undefined; + static stopError: unknown; + static isTypeSupported(type: string): boolean { + return this.supportedTypes.has(type); } state: RecordingState = "inactive"; + readonly mimeType: string; + readonly stream: MediaStream; + readonly options: MediaRecorderOptions | undefined; private readonly listeners = new Map>(); + constructor(stream: MediaStream, options?: MediaRecorderOptions) { + this.stream = stream; + this.options = options; + this.mimeType = + FakeMediaRecorder.outputMimeType ?? options?.mimeType ?? "video/browser-default"; + FakeMediaRecorder.instances.push(this); + } + addEventListener(type: string, listener: EventListenerOrEventListenerObject): void { const listeners = this.listeners.get(type) ?? new Set(); listeners.add(listener); @@ -116,6 +114,7 @@ class FakeMediaRecorder { } stop(): void { + if (FakeMediaRecorder.stopError !== undefined) throw FakeMediaRecorder.stopError; this.state = "inactive"; for (const listener of this.listeners.get("stop") ?? []) { if (typeof listener === "function") listener(new Event("stop")); @@ -124,52 +123,37 @@ class FakeMediaRecorder { } } -const emitRecordingFrame = () => { - frameSubscription.listener?.({ - tabId: "recording-tab", - data: "startup-frame", - width: 800, - height: 600, - receivedAt: "2026-06-26T00:00:00.000Z", - }); -}; - describe("browser recording", () => { + let animationFrameCount = 0; + beforeEach(() => { events.length = 0; - frameSubscription.listener = null; - surfaceState.byTabId = { - "recording-tab": { - visible: true, - rect: { x: 0, y: 0, width: 800, height: 600 }, - content: { x: 0, y: 0, width: 800, height: 600, scale: 1, scrollLeft: 0, scrollTop: 0 }, - }, - }; vi.clearAllMocks(); + FakeMediaRecorder.instances.length = 0; + FakeMediaRecorder.supportedTypes = new Set(["video/webm;codecs=vp9"]); + FakeMediaRecorder.outputMimeType = undefined; + FakeMediaRecorder.stopError = undefined; + clientSettings.browserRecordingFrameRate = 30; + animationFrameCount = 0; vi.stubGlobal("window", globalThis); + vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { + animationFrameCount += 1; + callback(animationFrameCount); + return animationFrameCount; + }); + vi.stubGlobal("cancelAnimationFrame", vi.fn()); vi.stubGlobal("MediaRecorder", FakeMediaRecorder as unknown as typeof MediaRecorder); - class ImmediateImage { - private loadListener: EventListenerOrEventListenerObject | undefined; - - addEventListener(type: string, listener: EventListenerOrEventListenerObject): void { - if (type === "load") this.loadListener = listener; - } - - set src(_value: string) { - const event = new Event("load"); - if (typeof this.loadListener === "function") this.loadListener(event); - else this.loadListener?.handleEvent(event); + getDisplayMedia.mockResolvedValue({ + getTracks: () => [{ stop: vi.fn() }], + }); + requestDisplayMediaCapture.mockImplementation((tabId: string) => { + const trigger = Reflect.get(globalThis, DESKTOP_PREVIEW_RECORDING_CAPTURE_TRIGGER); + if (typeof trigger !== "function" || trigger(tabId) !== true) { + throw new Error(`No pending display-media capture for ${tabId}.`); } - } - vi.stubGlobal("Image", ImmediateImage as unknown as typeof Image); - vi.stubGlobal("document", { - createElement: () => ({ - width: 0, - height: 0, - captureStream: () => ({}), - getContext: () => ({ drawImage: vi.fn(), fillRect: vi.fn(), fillStyle: "" }), - }), }); + vi.stubGlobal("navigator", { mediaDevices: { getDisplayMedia } }); + useBrowserSurfaceStore.setState({ activityByTabId: {}, byTabId: {} }); }); afterEach(() => { @@ -179,152 +163,190 @@ describe("browser recording", () => { it("starts recording for a visible tab", async () => { await startBrowserRecording("recording-tab"); - - expect(events).toEqual(["start-screencast", "publish:recording-tab"]); + const startupEvents = [...events]; await stopBrowserRecording("recording-tab"); + expect(startupEvents).toEqual(["publish:recording-tab", "start-screencast"]); }); - it("records a hidden tab without requiring it to become visible", async () => { - surfaceState.byTabId = { - "recording-tab": { - visible: false, - rect: { x: 0, y: 0, width: 800, height: 600 }, - content: { x: 0, y: 0, width: 800, height: 600, scale: 1, scrollLeft: 0, scrollTop: 0 }, - }, - }; + it("routes gesture-free starts through the desktop capture trigger", async () => { + await startBrowserRecording("automation-recording-tab"); - await startBrowserRecording("recording-tab"); + expect(requestDisplayMediaCapture).toHaveBeenCalledWith("automation-recording-tab"); + expect(getDisplayMedia).toHaveBeenCalledOnce(); + await stopBrowserRecording("automation-recording-tab"); + }); - expect(startScreencast).toHaveBeenCalledWith("recording-tab"); - expect(events).toEqual(["start-screencast", "publish:recording-tab"]); + it("paints and holds a hidden browser surface for the recording lifetime", async () => { + startScreencast.mockImplementationOnce(async (tabId: string) => { + expect(animationFrameCount).toBe(2); + expect(useBrowserSurfaceStore.getState().activityByTabId[tabId]).toBe(1); + }); + getDisplayMedia.mockImplementationOnce(async () => { + expect(animationFrameCount).toBe(2); + expect(useBrowserSurfaceStore.getState().activityByTabId["background-tab"]).toBe(1); + return { getTracks: () => [{ stop: vi.fn() }] }; + }); - await stopBrowserRecording("recording-tab"); + await startBrowserRecording("background-tab"); + expect(useBrowserSurfaceStore.getState().activityByTabId["background-tab"]).toBe(1); + + await stopBrowserRecording("background-tab"); + expect(useBrowserSurfaceStore.getState().activityByTabId["background-tab"]).toBeUndefined(); }); - it("fails startup instead of locking a fallback size when no frame arrives", async () => { + it("bounds compositor warmup when animation frames are paused", async () => { vi.useFakeTimers(); - startScreencast.mockImplementationOnce(async () => { - events.push("start-screencast"); - }); + const cancelAnimationFrame = vi.fn(); + vi.stubGlobal( + "requestAnimationFrame", + vi.fn(() => 42), + ); + vi.stubGlobal("cancelAnimationFrame", cancelAnimationFrame); - const startPromise = startBrowserRecording("recording-tab"); - const rejection = expect(startPromise).rejects.toMatchObject({ - operation: "wait-first-frame", - tabId: "recording-tab", + const startPromise = startBrowserRecording("hidden-window-tab"); + await vi.advanceTimersByTimeAsync(BROWSER_RECORDING_PAINT_SETTLE_TIMEOUT_MS); + + await startPromise; + expect(cancelAnimationFrame).toHaveBeenCalledWith(42); + await stopBrowserRecording("hidden-window-tab"); + }); + + it("records the native tab stream armed by the main process", async () => { + const stopTrack = vi.fn(); + const stream = { getTracks: () => [{ stop: stopTrack }] } as unknown as MediaStream; + getDisplayMedia.mockResolvedValueOnce(stream); + + await startBrowserRecording("recording-tab"); + + expect(getDisplayMedia).toHaveBeenCalledWith({ + audio: false, + video: { frameRate: { max: 30 } }, }); - await Promise.resolve(); - await vi.advanceTimersByTimeAsync(BROWSER_RECORDING_FIRST_FRAME_SIZE_TIMEOUT_MS); + expect(FakeMediaRecorder.instances[0]?.stream).toBe(stream); - await rejection; - expect(stopScreencast).toHaveBeenCalledWith("recording-tab"); - expect(events.at(-1)).toBe("clear"); + await stopBrowserRecording("recording-tab"); + expect(stopTrack).toHaveBeenCalledOnce(); }); - it("fixes hidden recording dimensions before MediaRecorder starts", async () => { - const drawImage = vi.fn(); - const fillRect = vi.fn(); - let capturedStreamSize: { readonly width: number; readonly height: number } | undefined; - const canvas = { - width: 0, - height: 0, - captureStream: () => { - capturedStreamSize = { width: canvas.width, height: canvas.height }; - return {}; - }, - getContext: () => ({ drawImage, fillRect, fillStyle: "" }), - }; - vi.stubGlobal("document", { - createElement: () => canvas, + it("uses the configured recording frame rate", async () => { + clientSettings.browserRecordingFrameRate = 60; + + await startBrowserRecording("recording-tab"); + + expect(getDisplayMedia).toHaveBeenCalledWith({ + audio: false, + video: { frameRate: { max: 60 } }, }); - surfaceState.byTabId = {}; - startScreencast.mockImplementationOnce(async (tabId: string) => { - events.push("start-screencast"); - frameSubscription.listener?.({ - tabId, - data: "captured-frame", - width: 390, - height: 844, - receivedAt: "2026-06-26T00:00:00.000Z", - }); + await stopBrowserRecording("recording-tab"); + }); + + it("stops the native stream when MediaRecorder cleanup fails", async () => { + const stopTrack = vi.fn(); + getDisplayMedia.mockResolvedValueOnce({ + getTracks: () => [{ stop: stopTrack }], }); await startBrowserRecording("recording-tab"); + FakeMediaRecorder.stopError = new Error("stop failed"); - expect(canvas).toMatchObject({ width: 390, height: 844 }); - expect(capturedStreamSize).toEqual({ width: 390, height: 844 }); - expect(drawImage).toHaveBeenCalledWith(expect.anything(), 0, 0, 390, 844); - - frameSubscription.listener?.({ + await expect(stopBrowserRecording("recording-tab")).rejects.toMatchObject({ + operation: "cleanup", tabId: "recording-tab", - data: "different-sized-frame", - width: 1280, - height: 720, - receivedAt: "2026-06-26T00:00:01.000Z", }); + expect(stopTrack).toHaveBeenCalledOnce(); + }); - expect(canvas).toMatchObject({ width: 390, height: 844 }); - expect(fillRect).toHaveBeenLastCalledWith(0, 0, 390, 844); + it("uses the best supported encoder and saves the recorder's actual format", async () => { + FakeMediaRecorder.supportedTypes = new Set([ + "video/mp4;codecs=avc1.42e01e", + "video/webm;codecs=vp9", + "video/webm;codecs=av1", + ]); + FakeMediaRecorder.outputMimeType = "video/webm;codecs=av01"; + await startBrowserRecording("recording-tab"); await stopBrowserRecording("recording-tab"); + + expect(FakeMediaRecorder.instances[0]?.options).toEqual({ + mimeType: "video/webm;codecs=av1", + }); + expect(save).toHaveBeenCalledWith( + "recording-tab", + "video/webm;codecs=av01", + expect.any(Uint8Array), + ); }); - it("draws the newest decoded frames without starving behind decode latency", async () => { - const drawImage = vi.fn(); - class DeferredImage { - static readonly instances: DeferredImage[] = []; - private loadListener: EventListenerOrEventListenerObject | undefined; + it("lets the browser select the format when no preferred encoding is supported", async () => { + FakeMediaRecorder.supportedTypes = new Set(); + FakeMediaRecorder.outputMimeType = "video/platform-default"; - constructor() { - DeferredImage.instances.push(this); - } + await startBrowserRecording("recording-tab"); + await stopBrowserRecording("recording-tab"); - addEventListener(type: string, listener: EventListenerOrEventListenerObject): void { - if (type === "load") this.loadListener = listener; - } + expect(FakeMediaRecorder.instances[0]?.options).toBeUndefined(); + expect(save).toHaveBeenCalledWith( + "recording-tab", + "video/platform-default", + expect.any(Uint8Array), + ); + }); - set src(_value: string) {} - - finishLoading(): void { - const event = new Event("load"); - if (typeof this.loadListener === "function") this.loadListener(event); - else this.loadListener?.handleEvent(event); - } - } - vi.stubGlobal("Image", DeferredImage as unknown as typeof Image); - vi.stubGlobal("document", { - createElement: () => ({ - width: 0, - height: 0, - captureStream: () => ({}), - getContext: () => ({ drawImage, fillRect: vi.fn(), fillStyle: "" }), - }), - }); + it("reports when MediaRecorder provides no output format", async () => { + FakeMediaRecorder.supportedTypes = new Set(); + FakeMediaRecorder.outputMimeType = ""; await startBrowserRecording("recording-tab"); - frameSubscription.listener?.({ + + await expect(stopBrowserRecording("recording-tab")).rejects.toBeInstanceOf( + BrowserRecordingFormatUnavailableError, + ); + expect(save).not.toHaveBeenCalled(); + expect(readActiveBrowserRecordingTabIds()).toEqual(new Set()); + }); + + it("releases the native capture lease when stream acquisition fails", async () => { + getDisplayMedia.mockRejectedValueOnce(new Error("capture failed")); + + await expect(startBrowserRecording("recording-tab")).rejects.toMatchObject({ + operation: "capture-media-stream", tabId: "recording-tab", - data: "second-frame", - width: 800, - height: 600, - receivedAt: "2026-06-26T00:00:01.000Z", }); - frameSubscription.listener?.({ + + expect(stopScreencast).toHaveBeenCalledWith("recording-tab"); + expect(events.at(-1)).toBe("clear"); + }); + + it("times out stalled stream acquisition and stops a late stream", async () => { + vi.useFakeTimers(); + let finishCapture!: (stream: MediaStream) => void; + const stopTrack = vi.fn(); + getDisplayMedia.mockImplementationOnce( + () => + new Promise((resolve) => { + finishCapture = resolve; + }), + ); + + const startPromise = startBrowserRecording("recording-tab"); + await vi.waitFor(() => expect(getDisplayMedia).toHaveBeenCalledOnce()); + const rejection = expect(startPromise).rejects.toMatchObject({ + _tag: "BrowserRecordingCaptureTimeoutError", tabId: "recording-tab", - data: "third-frame", - width: 800, - height: 600, - receivedAt: "2026-06-26T00:00:02.000Z", + timeoutMs: BROWSER_RECORDING_STARTUP_SETTLE_TIMEOUT_MS, }); + await vi.advanceTimersByTimeAsync(BROWSER_RECORDING_STARTUP_SETTLE_TIMEOUT_MS); - DeferredImage.instances[1]?.finishLoading(); - expect(drawImage).toHaveBeenCalledOnce(); - DeferredImage.instances[2]?.finishLoading(); - expect(drawImage).toHaveBeenCalledTimes(2); - DeferredImage.instances[0]?.finishLoading(); - expect(drawImage).toHaveBeenCalledTimes(2); + await rejection; + await expect(startPromise).rejects.toBeInstanceOf(BrowserRecordingCaptureTimeoutError); + expect(stopScreencast).toHaveBeenCalledWith("recording-tab"); + expect(readActiveBrowserRecordingTabIds()).toEqual(new Set()); + expect(useBrowserSurfaceStore.getState().activityByTabId["recording-tab"]).toBeUndefined(); - await stopBrowserRecording("recording-tab"); + finishCapture({ getTracks: () => [{ stop: stopTrack }] } as unknown as MediaStream); + await vi.advanceTimersByTimeAsync(0); + expect(stopTrack).toHaveBeenCalledOnce(); }); it("records separate tabs concurrently", async () => { @@ -336,22 +358,12 @@ describe("browser recording", () => { environmentId: EnvironmentId.make("environment-recording"), threadId: ThreadId.make("thread-recording-second"), }; - surfaceState.byTabId = { - ...surfaceState.byTabId, - "recording-tab-2": { - visible: false, - rect: { x: 0, y: 0, width: 390, height: 844 }, - content: { x: 0, y: 0, width: 390, height: 844, scale: 1, scrollLeft: 0, scrollTop: 0 }, - }, - }; - await Promise.all([ startBrowserRecording("recording-tab", firstThreadRef), startBrowserRecording("recording-tab-2", secondThreadRef), ]); expect(startScreencast).toHaveBeenCalledTimes(2); - expect(onFrame).toHaveBeenCalledOnce(); expect(events).toContain("publish:recording-tab,recording-tab-2"); expect(readActiveBrowserRecordingTabIds()).toEqual( new Set(["recording-tab", "recording-tab-2"]), @@ -366,28 +378,130 @@ describe("browser recording", () => { expect(save).toHaveBeenCalledTimes(2); }); + it("serializes display media grants for concurrent recording starts", async () => { + let finishFirstCapture!: (stream: MediaStream) => void; + const stream = { getTracks: () => [{ stop: vi.fn() }] } as unknown as MediaStream; + getDisplayMedia + .mockImplementationOnce( + () => + new Promise((resolve) => { + finishFirstCapture = resolve; + }), + ) + .mockResolvedValueOnce(stream); + + const firstStart = startBrowserRecording("recording-tab"); + await vi.waitFor(() => expect(getDisplayMedia).toHaveBeenCalledOnce()); + const secondStart = startBrowserRecording("recording-tab-2"); + await vi.waitFor(() => expect(readActiveBrowserRecordingTabIds().size).toBe(2)); + + expect(startScreencast).toHaveBeenCalledTimes(1); + finishFirstCapture(stream); + await Promise.all([firstStart, secondStart]); + + expect(startScreencast.mock.calls).toEqual([["recording-tab"], ["recording-tab-2"]]); + expect(getDisplayMedia).toHaveBeenCalledTimes(2); + await Promise.all([ + stopBrowserRecording("recording-tab"), + stopBrowserRecording("recording-tab-2"), + ]); + }); + + it("cancels a queued recording when stopped before its media grant", async () => { + let finishFirstCapture!: (stream: MediaStream) => void; + const stream = { getTracks: () => [{ stop: vi.fn() }] } as unknown as MediaStream; + getDisplayMedia.mockImplementationOnce( + () => + new Promise((resolve) => { + finishFirstCapture = resolve; + }), + ); + + const firstStart = startBrowserRecording("recording-tab"); + await vi.waitFor(() => expect(getDisplayMedia).toHaveBeenCalledOnce()); + const secondStart = startBrowserRecording("recording-tab-2"); + await vi.waitFor(() => expect(readActiveBrowserRecordingTabIds().size).toBe(2)); + + const secondStop = stopBrowserRecording("recording-tab-2"); + await expect(secondStart).rejects.toBeInstanceOf(BrowserRecordingStartCancelledError); + await expect(secondStop).resolves.toBeNull(); + expect(startScreencast).toHaveBeenCalledTimes(1); + + finishFirstCapture(stream); + await firstStart; + await stopBrowserRecording("recording-tab"); + expect(getDisplayMedia).toHaveBeenCalledOnce(); + }); + + it("latches a stop that arrives before the start becomes queued", async () => { + let releaseDelayedPaint!: (timestamp: number) => void; + let frameId = 0; + vi.stubGlobal( + "requestAnimationFrame", + vi.fn((callback: FrameRequestCallback) => { + frameId += 1; + if (frameId === 1) releaseDelayedPaint = callback; + else callback(frameId); + return frameId; + }), + ); + let finishBlockingCapture!: (stream: MediaStream) => void; + const stream = { getTracks: () => [{ stop: vi.fn() }] } as unknown as MediaStream; + getDisplayMedia.mockImplementationOnce( + () => + new Promise((resolve) => { + finishBlockingCapture = resolve; + }), + ); + + const delayedStart = startBrowserRecording("delayed-tab"); + await vi.waitFor(() => + expect(readActiveBrowserRecordingTabIds().has("delayed-tab")).toBe(true), + ); + const blockingStart = startBrowserRecording("blocking-tab"); + await vi.waitFor(() => expect(getDisplayMedia).toHaveBeenCalledOnce()); + + const delayedStop = stopBrowserRecording("delayed-tab"); + releaseDelayedPaint(1); + + await expect(delayedStart).rejects.toBeInstanceOf(BrowserRecordingStartCancelledError); + await expect(delayedStop).resolves.toBeNull(); + expect(startScreencast).toHaveBeenCalledOnce(); + + finishBlockingCapture(stream); + await blockingStart; + await stopBrowserRecording("blocking-tab"); + }); + + it("finishes an uncontended pre-grant start before stopping", async () => { + const animationFrames: FrameRequestCallback[] = []; + vi.stubGlobal( + "requestAnimationFrame", + vi.fn((callback: FrameRequestCallback) => { + animationFrames.push(callback); + return animationFrames.length; + }), + ); + + const startPromise = startBrowserRecording("recording-tab"); + await vi.waitFor(() => + expect(readActiveBrowserRecordingTabIds().has("recording-tab")).toBe(true), + ); + const stopPromise = stopBrowserRecording("recording-tab"); + expect(startScreencast).not.toHaveBeenCalled(); + + animationFrames.shift()?.(1); + animationFrames.shift()?.(2); + await startPromise; + await expect(stopPromise).resolves.toMatchObject({ tabId: "recording-tab" }); + }); + it("keeps a recording reachable through its runtime id after a server epoch changes", async () => { const threadRef = { environmentId: EnvironmentId.make("environment-recording"), threadId: ThreadId.make("thread-recording-scoped"), }; const runtimeTabId = previewRuntimeTabId(threadRef, "epoch-a", "tab_1"); - surfaceState.byTabId = { - [runtimeTabId]: { - visible: false, - rect: { x: 0, y: 0, width: 1280, height: 800 }, - content: { - x: 0, - y: 0, - width: 1280, - height: 800, - scale: 1, - scrollLeft: 0, - scrollTop: 0, - }, - }, - }; - await startBrowserRecording(runtimeTabId, threadRef, "tab_1"); expect(startScreencast).toHaveBeenCalledWith(runtimeTabId); @@ -408,15 +522,8 @@ describe("browser recording", () => { it("does not report success for a second start while the first is still starting", async () => { let finishStartingScreencast: (() => void) | undefined; - startScreencast.mockImplementationOnce(async (tabId: string) => { + startScreencast.mockImplementationOnce(async () => { events.push("start-screencast"); - frameSubscription.listener?.({ - tabId, - data: "initial-frame", - width: 800, - height: 600, - receivedAt: "2026-06-26T00:00:00.000Z", - }); await new Promise((resolve) => { finishStartingScreencast = resolve; }); @@ -484,7 +591,6 @@ describe("browser recording", () => { await new Promise((resolve) => { finishStartingScreencast = resolve; }); - emitRecordingFrame(); }); const startPromise = startBrowserRecording("recording-tab"); @@ -508,7 +614,6 @@ describe("browser recording", () => { await new Promise((resolve) => { finishStartingScreencast = resolve; }); - emitRecordingFrame(); }); const firstStart = startBrowserRecording("recording-tab"); @@ -535,7 +640,6 @@ describe("browser recording", () => { await new Promise((resolve) => { finishStartingScreencast = resolve; }); - emitRecordingFrame(); }); stopScreencast.mockRejectedValueOnce(new Error("initial stop failed")); @@ -569,15 +673,13 @@ describe("browser recording", () => { await new Promise((resolve) => { finishStartingScreencast = resolve; }); - emitRecordingFrame(); }); const startPromise = startBrowserRecording("recording-tab"); - expect(startScreencast).toHaveBeenCalledOnce(); + await vi.waitFor(() => expect(startScreencast).toHaveBeenCalledOnce()); const stopPromise = stopBrowserRecording("recording-tab"); - await Promise.resolve(); - await Promise.resolve(); + await vi.advanceTimersByTimeAsync(0); expect(stopScreencast).not.toHaveBeenCalled(); const rejection = expect(stopPromise).rejects.toMatchObject({ @@ -593,6 +695,7 @@ describe("browser recording", () => { ); finishStartingScreencast?.(); + await vi.advanceTimersByTimeAsync(32); await startPromise; const cleanupResult = await stopBrowserRecording("recording-tab"); expect(cleanupResult).toBeNull(); diff --git a/apps/web/src/browser/browserRecording.ts b/apps/web/src/browser/browserRecording.ts index 69297cfdbb85..73bc2708ddf6 100644 --- a/apps/web/src/browser/browserRecording.ts +++ b/apps/web/src/browser/browserRecording.ts @@ -1,15 +1,14 @@ -import type { - DesktopPreviewRecordingArtifact, - DesktopPreviewRecordingFrame, - ScopedThreadRef, -} from "@t3tools/contracts"; +import { DESKTOP_PREVIEW_RECORDING_CAPTURE_TRIGGER } from "@t3tools/contracts"; +import type { DesktopPreviewRecordingArtifact, ScopedThreadRef } from "@t3tools/contracts"; import { useAtomValue } from "@effect/atom-react"; import * as Schema from "effect/Schema"; import { Atom } from "effect/unstable/reactivity"; import { previewBridge } from "~/components/preview/previewBridge"; +import { ensureClientSettingsHydrated, getClientSettings } from "~/hooks/useSettings"; import { appAtomRegistry } from "~/rpc/atomRegistry"; -import { useBrowserSurfaceStore } from "./browserSurfaceStore"; + +import { acquireBrowserSurfaceActivity } from "./browserSurfaceStore"; export class BrowserRecordingUnavailableError extends Schema.TaggedErrorClass()( "BrowserRecordingUnavailableError", @@ -34,16 +33,35 @@ export class BrowserRecordingConflictError extends Schema.TaggedErrorClass()( - "BrowserRecordingCanvasUnavailableError", +export class BrowserRecordingStartCancelledError extends Schema.TaggedErrorClass()( + "BrowserRecordingStartCancelledError", { tabId: Schema.String, - width: Schema.Number, - height: Schema.Number, }, ) { override get message(): string { - return `Browser recording canvas ${this.width}x${this.height} is unavailable for tab ${this.tabId}.`; + return `Browser recording start was cancelled for tab ${this.tabId}.`; + } +} + +export class BrowserRecordingFormatUnavailableError extends Schema.TaggedErrorClass()( + "BrowserRecordingFormatUnavailableError", + { tabId: Schema.String }, +) { + override get message(): string { + return `MediaRecorder did not report an output format for tab ${this.tabId}.`; + } +} + +export class BrowserRecordingCaptureTimeoutError extends Schema.TaggedErrorClass()( + "BrowserRecordingCaptureTimeoutError", + { + tabId: Schema.String, + timeoutMs: Schema.Number, + }, +) { + override get message(): string { + return `Browser recording media capture for tab ${this.tabId} did not settle within ${this.timeoutMs}ms.`; } } @@ -52,11 +70,10 @@ export class BrowserRecordingOperationError extends Schema.TaggedErrorClass; + readonly cancelBeforeGrant: () => void; + readonly setQueuedForGrant: (queued: boolean) => void; +} type BrowserRecordingLifecycle = - | { readonly phase: "starting" } + | StartingBrowserRecordingLifecycle | { readonly phase: "recording" } | { readonly phase: "stopping"; @@ -82,23 +112,17 @@ type BrowserRecordingLifecycle = }; interface ActiveRecording { - /** Desktop-scoped identity used by capture and surface stores. */ + /** Desktop-scoped identity used by the native capture lease. */ readonly tabId: string; /** Server-local identity returned by preview automation tools. */ readonly serverTabId: string; readonly threadRef: ScopedThreadRef | null; - readonly canvas: HTMLCanvasElement; - readonly context: CanvasRenderingContext2D; readonly chunks: Blob[]; readonly startedAt: string; readonly startupSettled: Promise; - readonly firstFrameSize: Promise<"frame" | "cancelled">; - readonly settleFirstFrameSize: (outcome: "frame" | "cancelled") => void; + releaseSurfaceActivity: (() => void) | null; + stream: MediaStream | null; recorder: MediaRecorder | null; - mimeType: string | null; - frameSizeEstablished: boolean; - frameSequence: number; - lastDrawnFrameSequence: number; lifecycle: BrowserRecordingLifecycle; } @@ -120,10 +144,62 @@ export function useActiveBrowserRecordingTabIds(): ReadonlySet { } const activeRecordings = new Map(); -let unsubscribeFrames: (() => void) | null = null; +let displayMediaGrantTail = Promise.resolve(); +let displayMediaGrantQueueDepth = 0; + +const makeStartingBrowserRecordingLifecycle = (): StartingBrowserRecordingLifecycle => { + let signalCancellation!: () => void; + const cancelledBeforeGrantSignal = new Promise((resolve) => { + signalCancellation = resolve; + }); + const lifecycle: StartingBrowserRecordingLifecycle = { + phase: "starting", + queuedForGrant: null, + grantStarted: false, + stopRequestedBeforeGrant: false, + cancelledBeforeGrant: false, + cancelledBeforeGrantSignal, + cancelBeforeGrant: () => { + // Queue position is unknown during paint/settings warmup. Keep the stop request so a start + // that later turns out to be contended can still be cancelled before native capture. + lifecycle.stopRequestedBeforeGrant = true; + if (lifecycle.queuedForGrant && !lifecycle.grantStarted && !lifecycle.cancelledBeforeGrant) { + lifecycle.cancelledBeforeGrant = true; + signalCancellation(); + } + }, + setQueuedForGrant: (queued) => { + lifecycle.queuedForGrant = queued; + if (queued && lifecycle.stopRequestedBeforeGrant) lifecycle.cancelBeforeGrant(); + }, + }; + return lifecycle; +}; + +const queueDisplayMediaGrant = ( + useGrant: () => Promise, +): { readonly queued: boolean; readonly result: Promise } => { + const queued = displayMediaGrantQueueDepth > 0; + displayMediaGrantQueueDepth += 1; + const result = displayMediaGrantTail.then(useGrant); + const settleGrant = () => { + displayMediaGrantQueueDepth -= 1; + }; + displayMediaGrantTail = result.then( + () => settleGrant(), + () => settleGrant(), + ); + return { queued, result }; +}; + +const publishActiveRecordingTabIds = (): void => { + appAtomRegistry.set(activeBrowserRecordingTabIdsAtom, { + tabIds: new Set(activeRecordings.keys()), + }); +}; export const BROWSER_RECORDING_STARTUP_SETTLE_TIMEOUT_MS = 5_000; -export const BROWSER_RECORDING_FIRST_FRAME_SIZE_TIMEOUT_MS = 5_000; +export const BROWSER_RECORDING_PAINT_SETTLE_TIMEOUT_MS = 250; export function readActiveBrowserRecordingTabIds(threadRef?: ScopedThreadRef): ReadonlySet { const tabIds = new Set(); @@ -161,55 +237,27 @@ export function findActiveBrowserRecordingRuntimeTabId( ); } -const preferredMimeType = (): string => { - const candidates = ["video/mp4;codecs=avc1.42E01E", "video/webm;codecs=vp9", "video/webm"]; - return candidates.find((candidate) => MediaRecorder.isTypeSupported(candidate)) ?? "video/webm"; +const preferredMimeTypes = [ + "video/webm;codecs=av1", + "video/webm;codecs=vp9", + "video/mp4;codecs=avc1.640028", + "video/mp4;codecs=avc1.42e01e", + "video/webm;codecs=vp8", + "video/webm", +] as const; + +const createMediaRecorder = (stream: MediaStream): MediaRecorder => { + const mimeType = preferredMimeTypes.find((candidate) => MediaRecorder.isTypeSupported(candidate)); + return mimeType ? new MediaRecorder(stream, { mimeType }) : new MediaRecorder(stream); }; -const drawFrame = (frame: DesktopPreviewRecordingFrame): void => { - const recording = activeRecordings.get(frame.tabId); - if (!recording) return; - if ( - !Number.isFinite(frame.width) || - !Number.isFinite(frame.height) || - frame.width <= 0 || - frame.height <= 0 - ) { - return; - } - const width = Math.max(1, Math.round(frame.width)); - const height = Math.max(1, Math.round(frame.height)); - if (!recording.frameSizeEstablished) { - recording.canvas.width = width; - recording.canvas.height = height; - recording.frameSizeEstablished = true; - recording.settleFirstFrameSize("frame"); - } - const frameSequence = ++recording.frameSequence; - const image = new Image(); - image.addEventListener( - "load", - () => { - if ( - activeRecordings.get(frame.tabId) !== recording || - frameSequence <= recording.lastDrawnFrameSequence - ) { - return; - } - recording.lastDrawnFrameSequence = frameSequence; - const scale = Math.min(recording.canvas.width / width, recording.canvas.height / height); - const targetWidth = width * scale; - const targetHeight = height * scale; - const targetX = (recording.canvas.width - targetWidth) / 2; - const targetY = (recording.canvas.height - targetHeight) / 2; - recording.context.fillStyle = "#000000"; - recording.context.fillRect(0, 0, recording.canvas.width, recording.canvas.height); - recording.context.drawImage(image, targetX, targetY, targetWidth, targetHeight); - }, - { once: true }, - ); - image.src = `data:image/jpeg;base64,${frame.data}`; -}; +const captureTabMediaStream = (frameRate: number): Promise => + // The desktop main process routes this request to the tab that `startScreencast` armed, so the + // stream already arrives at that tab's native size and needs no source or dimension constraints. + navigator.mediaDevices.getDisplayMedia({ + audio: false, + video: { frameRate: { max: frameRate } }, + }); const stopMediaRecorder = async (recorder: MediaRecorder | null): Promise => { if (!recorder || recorder.state === "inactive") return; @@ -220,17 +268,135 @@ const stopMediaRecorder = async (recorder: MediaRecorder | null): Promise await stopped; }; +const stopMediaStream = (stream: MediaStream | null): void => { + for (const track of stream?.getTracks() ?? []) track.stop(); +}; + +interface PendingTabMediaCapture { + readonly start: () => void; +} + +const pendingTabMediaCaptures = new Map(); + +const prepareTabMediaCapture = (tabId: string, frameRate: number) => { + let acceptStream = true; + let capturedStream: MediaStream | null = null; + let resolveCapture!: (stream: MediaStream | PromiseLike) => void; + let rejectCapture!: (cause: unknown) => void; + const capturePromise = new Promise((resolve, reject) => { + resolveCapture = resolve; + rejectCapture = reject; + }).then((stream) => { + capturedStream = stream; + if (!acceptStream) { + stopMediaStream(stream); + capturedStream = null; + } + return stream; + }); + const pending: PendingTabMediaCapture = { + start: () => { + try { + // Electron invokes this callback through executeJavaScript(..., true), so even automated + // and delayed queued starts satisfy getDisplayMedia's transient-activation requirement. + resolveCapture(captureTabMediaStream(frameRate)); + } catch (cause) { + rejectCapture(cause); + } + }, + }; + pendingTabMediaCaptures.set(tabId, pending); + return { + capturePromise, + cancel: () => { + acceptStream = false; + if (capturedStream) { + stopMediaStream(capturedStream); + capturedStream = null; + } + if (pendingTabMediaCaptures.get(tabId) === pending) pendingTabMediaCaptures.delete(tabId); + void capturePromise.catch(() => undefined); + }, + }; +}; + +const triggerTabMediaCapture = (tabId: unknown): boolean => { + if (typeof tabId !== "string") return false; + const pending = pendingTabMediaCaptures.get(tabId); + if (!pending) return false; + pendingTabMediaCaptures.delete(tabId); + pending.start(); + return true; +}; + +Object.defineProperty(globalThis, DESKTOP_PREVIEW_RECORDING_CAPTURE_TRIGGER, { + configurable: true, + value: triggerTabMediaCapture, +}); + +const captureTabMediaStreamWithTimeout = async ( + tabId: string, + capturePromise: Promise, +): Promise => { + let acceptStream = true; + let timeoutId: number | null = null; + const streamPromise = capturePromise.then((stream) => { + if (!acceptStream) stopMediaStream(stream); + return stream; + }); + try { + return await Promise.race([ + streamPromise, + new Promise((_, reject) => { + timeoutId = window.setTimeout( + () => + reject( + new BrowserRecordingCaptureTimeoutError({ + tabId, + timeoutMs: BROWSER_RECORDING_STARTUP_SETTLE_TIMEOUT_MS, + }), + ), + BROWSER_RECORDING_STARTUP_SETTLE_TIMEOUT_MS, + ); + }), + ]); + } finally { + acceptStream = false; + if (timeoutId !== null) window.clearTimeout(timeoutId); + } +}; + const clearActiveRecording = (recording: ActiveRecording): void => { + recording.releaseSurfaceActivity?.(); + recording.releaseSurfaceActivity = null; if (activeRecordings.get(recording.tabId) !== recording) return; - recording.settleFirstFrameSize("cancelled"); activeRecordings.delete(recording.tabId); - if (activeRecordings.size === 0) { - unsubscribeFrames?.(); - unsubscribeFrames = null; - } - appAtomRegistry.set(activeBrowserRecordingTabIdsAtom, { - tabIds: new Set(activeRecordings.keys()), + publishActiveRecordingTabIds(); +}; + +const waitForBrowserRecordingPaint = async (): Promise => { + let firstFrameId: number | null = null; + let secondFrameId: number | null = null; + let timeoutId: number | null = null; + const painted = new Promise((resolve) => { + firstFrameId = window.requestAnimationFrame(() => { + firstFrameId = null; + secondFrameId = window.requestAnimationFrame(() => { + secondFrameId = null; + resolve(); + }); + }); + }); + const timedOut = new Promise((resolve) => { + timeoutId = window.setTimeout(resolve, BROWSER_RECORDING_PAINT_SETTLE_TIMEOUT_MS); }); + try { + await Promise.race([painted, timedOut]); + } finally { + if (timeoutId !== null) window.clearTimeout(timeoutId); + if (firstFrameId !== null) window.cancelAnimationFrame(firstFrameId); + if (secondFrameId !== null) window.cancelAnimationFrame(secondFrameId); + } }; const cleanupFailedRecordingStart = async ( @@ -247,6 +413,11 @@ const cleanupFailedRecordingStart = async ( await stopMediaRecorder(recording.recorder); } catch (error) { errors.push(error); + } + try { + stopMediaStream(recording.stream); + } catch (error) { + errors.push(error); } finally { clearActiveRecording(recording); } @@ -272,19 +443,6 @@ const recordingStartupCancelledError = ( const isRecordingStarting = (recording: ActiveRecording): boolean => activeRecordings.get(recording.tabId) === recording && recording.lifecycle.phase === "starting"; -const waitForFirstFrameSize = async (recording: ActiveRecording): Promise => { - if (recording.frameSizeEstablished) return true; - let timeout: ReturnType | null = null; - const outcome = await Promise.race([ - recording.firstFrameSize, - new Promise<"timeout">((resolve) => { - timeout = setTimeout(() => resolve("timeout"), BROWSER_RECORDING_FIRST_FRAME_SIZE_TIMEOUT_MS); - }), - ]); - if (timeout !== null) clearTimeout(timeout); - return outcome === "frame"; -}; - const waitForRecordingStartupToSettle = async (recording: ActiveRecording): Promise => { let timeout: ReturnType | null = null; try { @@ -335,75 +493,36 @@ export async function startBrowserRecording( activeTabId: activeLogicalRecording, }); } - const surface = useBrowserSurfaceStore.getState().byTabId[tabId]; - const recordingSize = surface?.content ?? surface?.rect; - const canvas = document.createElement("canvas"); - canvas.width = Math.max(1, recordingSize?.width ?? 1280); - canvas.height = Math.max(1, recordingSize?.height ?? 800); - const context = canvas.getContext("2d", { alpha: false }); - if (!context) { - throw new BrowserRecordingCanvasUnavailableError({ - tabId, - width: canvas.width, - height: canvas.height, - }); - } const startedAt = new Date().toISOString(); const chunks: Blob[] = []; let settleStartup: (() => void) | undefined; const startupSettled = new Promise((resolve) => { settleStartup = resolve; }); - let settleFirstFrameSize: ((outcome: "frame" | "cancelled") => void) | undefined; - const firstFrameSize = new Promise<"frame" | "cancelled">((resolve) => { - settleFirstFrameSize = resolve; - }); + const startingLifecycle = makeStartingBrowserRecordingLifecycle(); + const releaseSurfaceActivity = acquireBrowserSurfaceActivity(tabId); const recording: ActiveRecording = { tabId, serverTabId, threadRef, - canvas, - context, chunks, startedAt, startupSettled, - firstFrameSize, - settleFirstFrameSize: (outcome) => settleFirstFrameSize?.(outcome), + releaseSurfaceActivity, + stream: null, recorder: null, - mimeType: null, - frameSizeEstablished: false, - frameSequence: 0, - lastDrawnFrameSequence: 0, - lifecycle: { phase: "starting" }, + lifecycle: startingLifecycle, }; activeRecordings.set(tabId, recording); + publishActiveRecordingTabIds(); try { - try { - unsubscribeFrames ??= bridge.recording.onFrame(drawFrame); - } catch (cause) { - clearActiveRecording(recording); - throw new BrowserRecordingOperationError({ - operation: "subscribe-frames", - tabId, - cause, - }); - } - try { - await bridge.recording.startScreencast(tabId); - } catch (cause) { - if (!isRecordingStarting(recording)) { - throw recordingStartupCancelledError(recording, cause); - } - clearActiveRecording(recording); - throw new BrowserRecordingOperationError({ - operation: "start-screencast", - tabId, - cause, - }); - } + const frameRatePromise = ensureClientSettingsHydrated().then( + () => getClientSettings().browserRecordingFrameRate, + ); + const [frameRate] = await Promise.all([frameRatePromise, waitForBrowserRecordingPaint()]); const throwIfStartupCancelled = async (): Promise => { - // A stop requested during startup should let startup finish so the - // caller receives a real artifact. Only replacement/removal cancels it. + // Once a grant starts, a stop lets startup finish so the caller receives an artifact. + // Only a contended start can be cancelled before it reaches native capture. if (activeRecordings.get(tabId) === recording) return; try { await bridge.recording.stopScreencast(tabId); @@ -419,35 +538,67 @@ export async function startBrowserRecording( } throw recordingStartupCancelledError(recording); }; + // The desktop process exposes one display-media grant at a time. Keep only the + // arm-to-capture handoff exclusive; acquired streams can record concurrently. + const grant = queueDisplayMediaGrant(async () => { + if (startingLifecycle.cancelledBeforeGrant) { + throw new BrowserRecordingStartCancelledError({ tabId }); + } + startingLifecycle.grantStarted = true; + await throwIfStartupCancelled(); + const capture = prepareTabMediaCapture(tabId, frameRate); + try { + await bridge.recording.startScreencast(tabId); + } catch (cause) { + capture.cancel(); + if (!isRecordingStarting(recording)) { + throw recordingStartupCancelledError(recording, cause); + } + clearActiveRecording(recording); + throw new BrowserRecordingOperationError({ + operation: "start-screencast", + tabId, + cause, + }); + } + try { + await throwIfStartupCancelled(); + } catch (cause) { + capture.cancel(); + throw cause; + } + try { + recording.stream = await captureTabMediaStreamWithTimeout(tabId, capture.capturePromise); + return recording.stream; + } catch (cause) { + const cleanupCause = await cleanupFailedRecordingStart(bridge, recording); + if (isBrowserRecordingCaptureTimeoutError(cause) && cleanupCause === undefined) throw cause; + throw new BrowserRecordingOperationError({ + operation: "capture-media-stream", + tabId, + cause: + cleanupCause === undefined + ? cause + : new AggregateError( + [cause, cleanupCause], + `Browser media capture and cleanup failed for tab ${tabId}.`, + { cause }, + ), + }); + } + }); + startingLifecycle.setQueuedForGrant(grant.queued); + const stream = await Promise.race([ + grant.result, + startingLifecycle.cancelledBeforeGrantSignal.then(() => { + throw new BrowserRecordingStartCancelledError({ tabId }); + }), + ]); await throwIfStartupCancelled(); - const hasFirstFrame = await waitForFirstFrameSize(recording); - await throwIfStartupCancelled(); - if (!hasFirstFrame) { - const cause = new Error(`No valid recording frame arrived for tab ${tabId}.`); - const cleanupCause = await cleanupFailedRecordingStart(bridge, recording); - throw new BrowserRecordingOperationError({ - operation: "wait-first-frame", - tabId, - cause: - cleanupCause === undefined - ? cause - : new AggregateError( - [cause, cleanupCause], - `Browser recording frame wait and cleanup failed for tab ${tabId}.`, - { cause }, - ), - }); - } - let mimeType: string; let recorder: MediaRecorder; try { - mimeType = preferredMimeType(); - recorder = new MediaRecorder(canvas.captureStream(12), { - mimeType, - videoBitsPerSecond: 4_000_000, - }); - recording.mimeType = mimeType; + recorder = createMediaRecorder(stream); recording.recorder = recorder; recorder.addEventListener("dataavailable", (event) => { if (event.data.size > 0) chunks.push(event.data); @@ -487,9 +638,6 @@ export async function startBrowserRecording( if (recording.lifecycle.phase === "starting") { recording.lifecycle = { phase: "recording" }; } - appAtomRegistry.set(activeBrowserRecordingTabIdsAtom, { - tabIds: new Set(activeRecordings.keys()), - }); return startedAt; } finally { settleStartup?.(); @@ -518,7 +666,7 @@ const finalizeBrowserRecording = async ( cause, }); } - if (!recording.recorder || !recording.mimeType) { + if (!recording.recorder) { result = { _tag: "Success", artifact: null }; } else { try { @@ -530,11 +678,17 @@ const finalizeBrowserRecording = async ( cause, }); } + const mimeType = + recording.recorder.mimeType || + recording.chunks.find((chunk) => chunk.type.length > 0)?.type; + if (!mimeType) { + throw new BrowserRecordingFormatUnavailableError({ tabId }); + } try { - const blob = new Blob(recording.chunks, { type: recording.mimeType }); + const blob = new Blob(recording.chunks, { type: mimeType }); const artifact = await bridge.recording.save( tabId, - recording.mimeType, + mimeType, new Uint8Array(await blob.arrayBuffer()), ); result = { _tag: "Success", artifact }; @@ -558,18 +712,34 @@ const finalizeBrowserRecording = async ( throw result.error; } - let cleanupError: BrowserRecordingOperationError | undefined; + const cleanupErrors: unknown[] = []; try { await stopMediaRecorder(recording.recorder); } catch (cause) { - cleanupError = new BrowserRecordingOperationError({ - operation: "stop-media-recorder", - tabId, - cause, - }); + cleanupErrors.push(cause); + } + try { + stopMediaStream(recording.stream); + } catch (cause) { + cleanupErrors.push(cause); } finally { clearActiveRecording(recording); } + const cleanupError = + cleanupErrors.length === 0 + ? undefined + : new BrowserRecordingOperationError({ + operation: "cleanup", + tabId, + cause: + cleanupErrors.length === 1 + ? cleanupErrors[0] + : new AggregateError( + cleanupErrors, + `Browser recording media cleanup failed for tab ${tabId}.`, + { cause: cleanupErrors[0] }, + ), + }); if (result._tag === "Failure") { if (cleanupError) { @@ -596,6 +766,7 @@ const discardBrowserRecording = async ( try { await bridge.recording.stopScreencast(recording.tabId).catch(() => undefined); await stopMediaRecorder(recording.recorder).catch(() => undefined); + stopMediaStream(recording.stream); return null; } finally { clearActiveRecording(recording); @@ -609,6 +780,7 @@ export function stopBrowserRecording( const recording = activeRecordings.get(tabId); if (!bridge || !recording) return Promise.resolve(null); if (recording.lifecycle.phase === "stopping") return recording.lifecycle.stopPromise; + if (recording.lifecycle.phase === "starting") recording.lifecycle.cancelBeforeGrant(); const stopPromise = Promise.resolve() .then(() => finalizeBrowserRecording(bridge, recording)) diff --git a/apps/web/src/browser/browserSurfaceStore.test.ts b/apps/web/src/browser/browserSurfaceStore.test.ts index 12b34dd4b52c..249d3dcb2f44 100644 --- a/apps/web/src/browser/browserSurfaceStore.test.ts +++ b/apps/web/src/browser/browserSurfaceStore.test.ts @@ -2,13 +2,25 @@ import { beforeEach, describe, expect, it } from "vite-plus/test"; import { acquireBrowserSurface, + acquireBrowserSurfaceActivity, resolveBrowserSurfacePanelRect, useBrowserSurfaceStore, } from "./browserSurfaceStore"; describe("browserSurfaceStore", () => { beforeEach(() => { - useBrowserSurfaceStore.setState({ byTabId: {} }); + useBrowserSurfaceStore.setState({ activityByTabId: {}, byTabId: {} }); + }); + + it("keeps concurrent background work active until every lease is released", () => { + const first = acquireBrowserSurfaceActivity("background-browser"); + const second = acquireBrowserSurfaceActivity("background-browser"); + + first(); + expect(useBrowserSurfaceStore.getState().activityByTabId["background-browser"]).toBe(1); + + second(); + expect(useBrowserSurfaceStore.getState().activityByTabId["background-browser"]).toBeUndefined(); }); it("freezes the source content dimensions for a fitted presentation", () => { diff --git a/apps/web/src/browser/browserSurfaceStore.ts b/apps/web/src/browser/browserSurfaceStore.ts index 43ae0037c070..fe85c9e38b21 100644 --- a/apps/web/src/browser/browserSurfaceStore.ts +++ b/apps/web/src/browser/browserSurfaceStore.ts @@ -29,7 +29,9 @@ export interface BrowserSurfaceContentPresentation { } interface BrowserSurfaceStoreState { + readonly activityByTabId: Record; readonly byTabId: Record; + readonly acquireActivity: (tabId: string) => () => void; readonly claim: (tabId: string, owner: symbol, fitSourceContent: boolean) => void; readonly present: ( tabId: string, @@ -63,7 +65,28 @@ const rectEquals = (left: BrowserSurfaceRect | null, right: BrowserSurfaceRect): left.height === right.height; export const useBrowserSurfaceStore = create()((set) => ({ + activityByTabId: {}, byTabId: {}, + acquireActivity: (tabId) => { + let released = false; + set((state) => ({ + activityByTabId: { + ...state.activityByTabId, + [tabId]: (state.activityByTabId[tabId] ?? 0) + 1, + }, + })); + return () => { + if (released) return; + released = true; + set((state) => { + const count = state.activityByTabId[tabId] ?? 0; + const activityByTabId = { ...state.activityByTabId }; + if (count <= 1) delete activityByTabId[tabId]; + else activityByTabId[tabId] = count - 1; + return { activityByTabId }; + }); + }; + }, claim: (tabId, owner, fitSourceContent) => set((state) => { const current = state.byTabId[tabId]; @@ -171,6 +194,9 @@ export const useBrowserSurfaceStore = create()((set) = }), })); +export const acquireBrowserSurfaceActivity = (tabId: string): (() => void) => + useBrowserSurfaceStore.getState().acquireActivity(tabId); + export function acquireBrowserSurface( tabId: string, fitSourceContent = false, diff --git a/apps/web/src/browser/hostedBrowserWebviewStyle.test.ts b/apps/web/src/browser/hostedBrowserWebviewStyle.test.ts index d0298dcdee7a..69216796af9f 100644 --- a/apps/web/src/browser/hostedBrowserWebviewStyle.test.ts +++ b/apps/web/src/browser/hostedBrowserWebviewStyle.test.ts @@ -10,6 +10,7 @@ describe("resolveHostedBrowserWebviewWrapperStyle", () => { expect( resolveHostedBrowserWebviewWrapperStyle({ active: true, + renderingActive: true, rect: { x: 12, y: 34, width: 800, height: 600 }, hiddenSize: { width: 1280, height: 800 }, }), @@ -27,6 +28,7 @@ describe("resolveHostedBrowserWebviewWrapperStyle", () => { expect( resolveHostedBrowserWebviewWrapperStyle({ active: true, + renderingActive: true, cornerRadius: 12, rect: { x: 12, y: 34, width: 360, height: 203 }, hiddenSize: { width: 1280, height: 800 }, @@ -40,9 +42,10 @@ describe("resolveHostedBrowserWebviewWrapperStyle", () => { }); }); - it("keeps an inactive webview paintable while moving it offscreen", () => { + it("suspends painting for an inactive webview", () => { const style = resolveHostedBrowserWebviewWrapperStyle({ active: false, + renderingActive: false, rect: { x: 12, y: 34, width: 800, height: 600 }, hiddenSize: { width: 393, height: 852 }, }); @@ -54,6 +57,45 @@ describe("resolveHostedBrowserWebviewWrapperStyle", () => { height: 852, zIndex: -1, pointerEvents: "none", + visibility: "hidden", + }); + }); + + it("keeps an active background task paintable behind the app", () => { + const style = resolveHostedBrowserWebviewWrapperStyle({ + active: false, + renderingActive: true, + rect: null, + hiddenSize: { width: 1280, height: 800 }, + }); + + expect(style).toEqual({ + left: 0, + top: 0, + width: 1280, + height: 800, + zIndex: -1, + pointerEvents: "none", + visibility: "visible", + }); + }); + + it("keeps an inactive webview paintable without marking it as rendering-active", () => { + const style = resolveHostedBrowserWebviewWrapperStyle({ + active: false, + renderingActive: false, + keepPaintableWhenInactive: true, + rect: null, + hiddenSize: { width: 1280, height: 800 }, + }); + + expect(style).toEqual({ + left: HIDDEN_BROWSER_WEBVIEW_OFFSET, + top: HIDDEN_BROWSER_WEBVIEW_OFFSET, + width: 1280, + height: 800, + zIndex: -1, + pointerEvents: "none", visibility: "visible", }); }); diff --git a/apps/web/src/browser/hostedBrowserWebviewStyle.ts b/apps/web/src/browser/hostedBrowserWebviewStyle.ts index f96f4af0462a..a59a4a8b0083 100644 --- a/apps/web/src/browser/hostedBrowserWebviewStyle.ts +++ b/apps/web/src/browser/hostedBrowserWebviewStyle.ts @@ -13,18 +13,27 @@ export interface HostedBrowserWebviewWrapperStyle { readonly zIndex: number; readonly pointerEvents: "auto" | "none"; readonly borderRadius?: number; - readonly visibility?: "visible"; + readonly visibility?: "hidden" | "visible"; } export const HIDDEN_BROWSER_WEBVIEW_OFFSET = -100_000; export function resolveHostedBrowserWebviewWrapperStyle(input: { readonly active: boolean; + readonly renderingActive: boolean; + readonly keepPaintableWhenInactive?: boolean; readonly cornerRadius?: number; readonly rect: BrowserSurfaceRect | null; readonly hiddenSize: HostedBrowserWebviewSize; }): HostedBrowserWebviewWrapperStyle { - const { active, cornerRadius = 0, hiddenSize, rect } = input; + const { + active, + cornerRadius = 0, + hiddenSize, + keepPaintableWhenInactive = false, + rect, + renderingActive, + } = input; if (active && rect) { return { left: rect.x, @@ -37,6 +46,21 @@ export function resolveHostedBrowserWebviewWrapperStyle(input: { }; } + if (renderingActive) { + // Electron stops compositing a guest that is fully outside the window, even + // when background throttling is disabled. Keep capture-active guests inside + // the viewport but behind the app so recordings receive complete frames. + return { + left: 0, + top: 0, + width: hiddenSize.width, + height: hiddenSize.height, + zIndex: -1, + pointerEvents: "none", + visibility: "visible", + }; + } + return { left: HIDDEN_BROWSER_WEBVIEW_OFFSET, top: HIDDEN_BROWSER_WEBVIEW_OFFSET, @@ -44,9 +68,6 @@ export function resolveHostedBrowserWebviewWrapperStyle(input: { height: hiddenSize.height, zIndex: -1, pointerEvents: "none", - // Keep the guest CSS-visible even while physically offscreen. Electron - // webviews can keep metadata/status alive under `visibility:hidden` while - // CDP Runtime/Input commands stall, which breaks offscreen automation. - visibility: "visible", + visibility: keepPaintableWhenInactive ? "visible" : "hidden", }; } diff --git a/apps/web/src/cloud/linkEnvironment.test.ts b/apps/web/src/cloud/linkEnvironment.test.ts index 38e205beabbb..7ae5e7ed03a9 100644 --- a/apps/web/src/cloud/linkEnvironment.test.ts +++ b/apps/web/src/cloud/linkEnvironment.test.ts @@ -91,6 +91,7 @@ function registryLayer(options?: { const session: RpcSession = { client, initialConfig: Effect.never, + subscribeServerConfig: (input) => client.subscribeServerConfig(input), ready: Effect.void, probe: Effect.void, closed: Effect.never, diff --git a/apps/web/src/cloud/linkEnvironment.ts b/apps/web/src/cloud/linkEnvironment.ts index a245cbc54db2..a267a6e07572 100644 --- a/apps/web/src/cloud/linkEnvironment.ts +++ b/apps/web/src/cloud/linkEnvironment.ts @@ -19,13 +19,12 @@ import { type RelayClientDeviceRecord, type RelayClientEnvironmentRecord, type RelayEnvironmentLinkResponse, - type RelayProtectedError as RelayProtectedErrorType, type RelayManagedEndpointProviderKind, } from "@t3tools/contracts/relay"; import { EnvironmentRegistry } from "@t3tools/client-runtime/connection"; import { request, runStream } from "@t3tools/client-runtime/rpc"; import { makeEnvironmentHttpApiClient } from "@t3tools/client-runtime/rpc"; -import { ManagedRelay } from "@t3tools/client-runtime/relay"; +import { ManagedRelay, relayProtectedErrorMessage } from "@t3tools/client-runtime/relay"; import { readPrimaryEnvironmentDescriptor, @@ -128,50 +127,6 @@ const isEnvironmentCloudApiError = Schema.is( ]), ); -function relayProtectedErrorMessage(error: RelayProtectedErrorType): string { - switch (error._tag) { - case "RelayAuthInvalidError": - switch (error.reason) { - case "missing_bearer": - case "invalid_bearer": - return "Relay rejected the cloud session token."; - case "invalid_dpop": - return "Relay rejected the DPoP proof."; - case "not_authorized": - return "Relay rejected the authenticated request."; - } - case "RelayEnvironmentLinkProofExpiredError": - return "Relay rejected an expired environment link proof."; - case "RelayEnvironmentLinkProofInvalidError": - return `Relay rejected the environment link proof (${error.reason}).`; - case "RelayEnvironmentConnectNotAuthorizedError": - // "Not authorized" covers non-auth causes too; surface the reason so a - // missing link doesn't read as a credential problem. - if (error.reason === "environment_link_not_found") { - return "Relay has no active link for this environment. The environment server may not have re-established its link yet."; - } - return error.reason - ? `Relay rejected the environment connection request (${error.reason}).` - : "Relay rejected the environment connection request."; - case "RelayEnvironmentEndpointUnavailableError": - return `Relay could not reach the environment endpoint (${error.reason}).`; - case "RelayEnvironmentEndpointTimedOutError": - return "Relay timed out while contacting the environment endpoint."; - case "RelayEnvironmentLinkFailedError": - return `Relay could not link the environment (${error.reason}).`; - case "RelayEnvironmentLinkUnavailableError": - return `Relay cannot provision the managed endpoint (${error.reason}).`; - case "RelayEnvironmentLinkLimitExceededError": - return `Relay refused the link: this account already has its maximum of ${error.maxTunnels} managed tunnels. Unlink an environment to free one up.`; - case "RelayAgentActivityPublishProofExpiredError": - return "Relay rejected an expired agent activity publish proof."; - case "RelayAgentActivityPublishProofInvalidError": - return `Relay rejected the agent activity publish proof (${error.reason}).`; - case "RelayInternalError": - return `Relay encountered an internal error (${error.reason}).`; - } -} - function decodedRelayClientError(message: string) { return (cause: ManagedRelay.ManagedRelayClientError) => { const relayError = diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 5d11cce11fbe..b0b1440587ea 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -40,6 +40,7 @@ import { MenuTrigger, } from "./ui/menu"; import { Separator } from "./ui/separator"; +import { ComposerSurface } from "./chat/ComposerSurface"; interface BranchToolbarProps { environmentId: EnvironmentId; @@ -264,8 +265,10 @@ function useLabelsOverflow(element: HTMLDivElement | null): boolean { let needed = 0; let groups = 0; for (const child of current.children) { - if (!(child instanceof HTMLElement) || child.offsetWidth <= 1) continue; - needed += contentWidth(child); + if (!(child instanceof HTMLElement)) continue; + const width = contentWidth(child); + if (width <= 1) continue; + needed += width; groups += 1; } needed += stripGap * Math.max(0, groups - 1); @@ -355,7 +358,7 @@ function useLabelsOverflow(element: HTMLDivElement | null): boolean { // Label widths can change without the strip box moving (font family or // size preferences), so re-measure on every render as well as on resize // and font loads. - useEffect(() => { + useLayoutEffect(() => { measure(); }); @@ -466,10 +469,9 @@ export const BranchToolbar = memo(function BranchToolbar({ if (!hasActiveThread || !activeProject) return null; return ( -
{isMobile && showGitControls ? ( ) : ( -
+
{showEnvironmentIndicator && availableEnvironments && ( <> ) : null} -
+ ); }); diff --git a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx index 9fc2d4892e27..23589d62bd95 100644 --- a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx @@ -51,20 +51,25 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe if (envLocked) { return ( {activeWorktreePath ? ( - <> - - {resolveLockedWorkspaceLabel(activeWorktreePath)} - + ) : ( - <> - - {resolveLockedWorkspaceLabel(activeWorktreePath)} - + )} + + + {resolveLockedWorkspaceLabel(activeWorktreePath)} + + ); } diff --git a/apps/web/src/components/ChatMarkdown.test.tsx b/apps/web/src/components/ChatMarkdown.test.tsx index a8c82552f9bb..7e32e48d585c 100644 --- a/apps/web/src/components/ChatMarkdown.test.tsx +++ b/apps/web/src/components/ChatMarkdown.test.tsx @@ -1,6 +1,359 @@ -import { describe, expect, it } from "vite-plus/test"; +import { EnvironmentId } from "@t3tools/contracts"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vite-plus/test"; -import { orderedListGutterStyle } from "./ChatMarkdown"; +vi.mock("@effect/atom-react", () => ({ useAtomValue: () => null })); +vi.mock("../hooks/useTheme", () => ({ useTheme: () => ({ resolvedTheme: "dark" }) })); +vi.mock("../state/use-atom-query-runner", () => ({ useAtomQueryRunner: () => vi.fn() })); +vi.mock("../state/use-atom-command", () => ({ useAtomCommand: () => vi.fn() })); +vi.mock("../state/session", async (importOriginal) => ({ + ...(await importOriginal()), + usePreparedConnection: () => ({ _tag: "Loading" }), +})); +vi.mock("../state/entities", () => ({ + readThreadShell: () => null, + useProjects: () => [], +})); +vi.mock("../remoteOpen", () => ({ + useRemoteOpenResolution: () => ({ state: { mode: "local-exec" }, isResolved: true }), +})); +vi.mock("../editorPreferences", () => ({ + useOpenInPreferredEditor: () => vi.fn(), + usePreferredEditor: () => [null, vi.fn()], +})); +vi.mock("~/lib/openPullRequestLink", () => ({ + findProjectForChangeRequest: () => undefined, + matchesLinkedPullRequestUrl: () => false, + parseChangeRequestUrl: () => null, + useOpenChangeRequestLink: () => vi.fn(), +})); + +import ChatMarkdown, { + canUseMarkdownFileShellActions, + hasMarkdownFilePrimaryAction, + orderedListGutterStyle, + shouldUseMarkdownFileBrowserPrimaryAction, +} from "./ChatMarkdown"; + +describe("canUseMarkdownFileShellActions", () => { + const environmentId = EnvironmentId.make("environment-1"); + + it("allows editor and file manager actions for local environments", () => { + expect(canUseMarkdownFileShellActions(environmentId, "local-exec", true)).toBe(true); + }); + + it("hides shell actions until the environment mode is resolved", () => { + expect(canUseMarkdownFileShellActions(environmentId, "local-exec", false)).toBe(false); + }); + + it("hides editor and file manager actions for remote environments", () => { + expect(canUseMarkdownFileShellActions(environmentId, "remote-links", true)).toBe(false); + expect(canUseMarkdownFileShellActions(environmentId, "remote-unavailable", true)).toBe(false); + }); + + it("hides shell actions when no environment owns the markdown", () => { + expect(canUseMarkdownFileShellActions(null, "local-exec", true)).toBe(false); + }); +}); + +describe("hasMarkdownFilePrimaryAction", () => { + it("keeps the chip interactive when an editor, browser, or panel can open it", () => { + expect( + hasMarkdownFilePrimaryAction({ + canOpenInEditor: true, + canOpenInBrowser: false, + canOpenInPanel: false, + }), + ).toBe(true); + expect( + hasMarkdownFilePrimaryAction({ + canOpenInEditor: false, + canOpenInBrowser: true, + canOpenInPanel: false, + }), + ).toBe(true); + expect( + hasMarkdownFilePrimaryAction({ + canOpenInEditor: false, + canOpenInBrowser: false, + canOpenInPanel: true, + }), + ).toBe(true); + }); + + it("removes the link affordance when no primary action can open the file", () => { + expect( + hasMarkdownFilePrimaryAction({ + canOpenInEditor: false, + canOpenInBrowser: false, + canOpenInPanel: false, + }), + ).toBe(false); + }); +}); + +describe("ChatMarkdown file option chips", () => { + it("keeps the fallback button text selectable", () => { + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain(" { + const html = renderToStaticMarkup( + , + ); + + expect(html).not.toContain("codex-file-citation"); + expect(html).toContain("chat-markdown-file-link"); + expect(html).toContain( + 'data-markdown-copy="[report.xlsx](/tmp/project/outputs/report.xlsx)"', + ); + expect(html).toContain("report.xlsx"); + }, + ); + + it("leaves an unfinished streaming citation visible until it is complete", () => { + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain(":codex-file-citation"); + expect(html).not.toContain("chat-markdown-file-link"); + }); + + it("leaves malformed and similarly named file directives literal", () => { + for (const text of [ + ':codex-file-citation{purpose="output"}', + ':codex-file-citation-extra{path="/tmp/project/outputs/report.xlsx"}', + ]) { + const html = renderToStaticMarkup(); + + expect(html).toContain(text.replaceAll('"', """)); + expect(html).not.toContain("chat-markdown-file-link"); + } + }); + + it("preserves Codex file citation examples inside code", () => { + const directive = ':codex-file-citation{path="/tmp/project/outputs/report.xlsx"}'; + const html = renderToStaticMarkup( + , + ); + + expect(html.match(/:codex-file-citation/g)).toHaveLength(2); + expect(html).not.toContain("chat-markdown-file-link"); + }); + + it("preserves escaped Codex file citations as literal text", () => { + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain(":codex-file-citation"); + expect(html).not.toContain("chat-markdown-file-link"); + }); + + it("does not create a nested link for citations inside link text", () => { + const directive = ':codex-file-citation{path="/tmp/project/outputs/report.xlsx"}'; + const html = renderToStaticMarkup( + , + ); + const renderedText = html.replace(/<[^>]+>/g, ""); + + expect(renderedText).toContain("codex-file-citation"); + expect(html).not.toContain("chat-markdown-file-link"); + }); + + it("renders file citations created by over-indented list recovery", () => { + const html = renderToStaticMarkup( + , + ); + + expect(html).not.toContain("
");
+    expect(html).toContain("Created ");
+    expect(html).toContain("chat-markdown-file-link");
+    expect(html).toContain("report.xlsx");
+  });
+
+  it("disambiguates Codex citations with the same basename", () => {
+    const html = renderToStaticMarkup(
+      ,
+    );
+
+    expect(html).toContain("index.ts · project/src");
+    expect(html).toContain("index.ts · project/test");
+  });
+
+  it("preserves rejected citations created by over-indented list recovery", () => {
+    const malformedHtml = renderToStaticMarkup(
+      ,
+    );
+    const nestedLinkHtml = renderToStaticMarkup(
+      ,
+    );
+    const nestedLinkText = nestedLinkHtml.replace(/<[^>]+>/g, "");
+
+    expect(malformedHtml).toContain(
+      "
  • Bad :codex-file-citation{purpose="output"}
  • ", + ); + expect(nestedLinkText).toContain( + "Bad :codex-file-citation{path="/tmp/project/report.xlsx"}", + ); + }); +}); + +const ARTIFACT_TEMPLATE_DIRECTIVE = + '::artifact-template{skill_name="artifact-template-hello-world" skill_directory="/Users/test/.codex/skills/artifact-template-hello-world" display_name="Hello World" artifact_kind="document"}'; + +describe("ChatMarkdown artifact-template cards", () => { + it.each([true, false])("renders the Codex result card with parseRawHtml=%s", (parseRawHtml) => { + const html = renderToStaticMarkup( + undefined} + />, + ); + + expect(html).not.toContain("::artifact-template"); + expect(html).toContain("chat-markdown-artifact-template"); + expect(html).toContain('data-artifact-kind="document"'); + expect(html).toContain('data-markdown-copy="Hello World (Document template)\n\n"'); + expect(html).toContain('data-skill-name="artifact-template-hello-world"'); + expect(html).toContain("Hello World"); + expect(html).toContain("Document template"); + expect(html).toContain("Use template"); + expect(html).not.toContain("

    { + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain("chat-markdown-artifact-template"); + expect(html).not.toContain("Use template"); + }); + + it("leaves malformed and unfinished artifact-template directives literal", () => { + const malformed = + '::artifact-template{skill_name="artifact-template-hello-world" display_name="Hello World" artifact_kind="document"}'; + const unfinished = ARTIFACT_TEMPLATE_DIRECTIVE.slice(0, -1); + + for (const text of [malformed, unfinished]) { + const html = renderToStaticMarkup(); + expect(html).toContain("::artifact-template"); + expect(html).not.toContain("chat-markdown-artifact-template"); + } + }); + + it("leaves escaped and similarly named artifact-template directives literal", () => { + for (const text of [ + `\\${ARTIFACT_TEMPLATE_DIRECTIVE}`, + ARTIFACT_TEMPLATE_DIRECTIVE.replace("::artifact-template", "::artifact-template-extra"), + ]) { + const html = renderToStaticMarkup(); + + expect(html).toContain("::artifact-template"); + expect(html).not.toContain("chat-markdown-artifact-template"); + } + }); + + it("preserves artifact-template examples inside code", () => { + const html = renderToStaticMarkup( + , + ); + + expect(html.match(/::artifact-template/g)).toHaveLength(2); + expect(html).not.toContain("chat-markdown-artifact-template"); + }); +}); + +describe("shouldUseMarkdownFileBrowserPrimaryAction", () => { + it("uses the browser when it is the only available primary action", () => { + expect( + shouldUseMarkdownFileBrowserPrimaryAction({ + iconPath: "/tmp/report.html", + canOpenInEditor: false, + canOpenInBrowser: true, + canOpenInPanel: false, + }), + ).toBe(true); + }); + + it("preserves the normal editor and panel defaults for HTML files", () => { + expect( + shouldUseMarkdownFileBrowserPrimaryAction({ + iconPath: "/tmp/report.html", + canOpenInEditor: true, + canOpenInBrowser: true, + canOpenInPanel: false, + }), + ).toBe(false); + expect( + shouldUseMarkdownFileBrowserPrimaryAction({ + iconPath: "/tmp/report.html", + canOpenInEditor: false, + canOpenInBrowser: true, + canOpenInPanel: true, + }), + ).toBe(false); + }); + + it("continues to open PDF files in the browser by default", () => { + expect( + shouldUseMarkdownFileBrowserPrimaryAction({ + iconPath: "/tmp/report.pdf", + canOpenInEditor: true, + canOpenInBrowser: true, + canOpenInPanel: true, + }), + ).toBe(true); + }); +}); describe("orderedListGutterStyle", () => { it("leaves the default gutter alone for single-digit lists", () => { @@ -42,3 +395,105 @@ describe("orderedListGutterStyle", () => { expect(orderedListGutterStyle(0, 100)).toEqual({ "--list-gutter": "4ch" }); }); }); + +describe("ChatMarkdown Windows file links", () => { + const environmentId = EnvironmentId.make("env-windows"); + + it.each([true, false])("preserves drive paths with parseRawHtml=%s", (parseRawHtml) => { + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain('href="C:/Users/shawn/project/src/main.ts"'); + expect(html).toContain("chat-markdown-file-link"); + }); + + it.each([true, false])("normalizes backslashes with parseRawHtml=%s", (parseRawHtml) => { + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain('href="C:/Users/shawn/project/src/main.ts"'); + expect(html).toContain("chat-markdown-file-link"); + }); + + it.each([true, false])( + "distinguishes same-named backslash paths with parseRawHtml=%s", + (parseRawHtml) => { + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain("index.ts · project/src"); + expect(html).toContain("index.ts · project/test"); + }, + ); + + it.each([true, false])( + "does not disambiguate the same file in links and inline code with parseRawHtml=%s", + (parseRawHtml) => { + const path = String.raw`C:\Users\shawn\project\src\main.ts`; + const html = renderToStaticMarkup( + , + ); + + expect(html.match(/chat-markdown-file-link/g)).toHaveLength(2); + expect(html).not.toContain("main.ts ·"); + }, + ); + + it.each([true, false])("preserves reference links with parseRawHtml=%s", (parseRawHtml) => { + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain('href="C:/Users/shawn/project/src/main.ts"'); + expect(html).toContain("chat-markdown-file-link"); + }); + + it.each([true, false])("still rejects unsafe schemes with parseRawHtml=%s", (parseRawHtml) => { + const html = renderToStaticMarkup( + , + ); + + expect(html).not.toContain("javascript:"); + expect(html).not.toContain("d:alert"); + expect(html).not.toContain("chat-markdown-file-link"); + }); +}); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 13024a7516ff..6f7327b3ab3e 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -3,29 +3,56 @@ import { CheckIcon, ChevronRightIcon, CopyIcon, + FileSpreadsheetIcon, + FileTextIcon, GlobeIcon, + ImageIcon, InfoIcon, LightbulbIcon, + MailIcon, Maximize2Icon, + MessageSquareIcon, MessageSquareWarningIcon, Minimize2Icon, OctagonAlertIcon, + PresentationIcon, + SparklesIcon, TriangleAlertIcon, WrapTextIcon, + type LucideIcon, } from "lucide-react"; -import type { ScopedThreadRef, ServerProviderSkill } from "@t3tools/contracts"; +import type { + AssetResource, + EnvironmentId, + ScopedThreadRef, + ServerProviderSkill, + ThreadLinkedPullRequest, +} from "@t3tools/contracts"; import { isAtomCommandInterrupted, squashAtomCommandFailure, type AtomCommandResult, } from "@t3tools/client-runtime/state/runtime"; -import { classifyMarkdownImageSource } from "@t3tools/client-runtime/markdown-images"; +import { + codexArtifactTemplatePresentationLabel, + type CodexArtifactTemplate, + type CodexArtifactTemplateKind, +} from "@t3tools/client-runtime/codex-artifact-templates"; +import { + classifyMarkdownImageSource, + markdownImageSourceFragment, +} from "@t3tools/client-runtime/markdown-images"; +import { inlineCodeFilePathCandidate } from "@t3tools/client-runtime/markdown-links"; +import { mediaFileReference, mediaUrlReference } from "@t3tools/client-runtime/media-reference"; +import { mediaKindFromPath, mediaMimeTypeFromExtension } from "@t3tools/shared/filePreview"; import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/unstable/reactivity"; import React, { Children, Suspense, + type CSSProperties, type ClipboardEvent as ReactClipboardEvent, + type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, isValidElement, use, @@ -45,9 +72,27 @@ import rehypeSanitize, { defaultSchema } from "rehype-sanitize"; import remarkBreaks from "remark-breaks"; import remarkGfm from "remark-gfm"; import { remarkGithubAlerts } from "../markdown-github-alerts"; +import { + artifactTemplateFromHastProperties, + CODEX_ARTIFACT_TEMPLATE_HAST_PROPERTIES, + remarkCodexDirectives, + renderCodexFileCitationsAsMarkdown, +} from "@t3tools/client-runtime/codex-markdown-directives"; import { renderSkillInlineMarkdownChildren } from "./chat/SkillInlineText"; +import { + resolveMarkdownMediaPreview, + type ExpandedImagePreview, +} from "./chat/ExpandedImagePreview"; +import { ExpandedImageDialog } from "./chat/ExpandedImageDialog"; +import { MediaVideoPlayer } from "./media/MediaVideoPlayer"; +import { MediaActions, type MediaActionSource } from "./media/MediaActions"; +import { resolveProtocolRelativeMediaUrl } from "./media/mediaContent"; import { CHAT_FILE_TAG_CHIP_CLASS_NAME, FileTagChipContent } from "./chat/FileTagChip"; import { PierreEntryIcon } from "./chat/PierreEntryIcon"; +import { + revealInFileExplorerLabelForKind, + revealInFileExplorerLabelForOs, +} from "./preview/fileExplorerLabel"; import { resolveExternalWebLinkHost, showExternalLinkContextMenu, @@ -60,7 +105,12 @@ import { ScrollArea } from "./ui/scroll-area"; import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu"; import { stackedThreadToast, toastManager } from "./ui/toast"; import { recordVisitForThread } from "../browserHistoryStore"; -import { useOpenInPreferredEditor } from "../editorPreferences"; +import { + PreferredEditorEnvironmentRequiredError, + useOpenInPreferredEditor, + usePreferredEditor, +} from "../editorPreferences"; +import { openInEditorMenuLabel } from "../editorLabels"; import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering"; import { fnv1a32 } from "../lib/diffRendering"; import { LRUCache } from "../lib/lruCache"; @@ -76,34 +126,45 @@ import { import { remarkNormalizeListItemIndentation } from "../markdown-list-indentation"; import { extractMarkdownLinkHrefs, + isWindowsDrivePathHref, normalizeMarkdownLinkDestination, resolveInlineCodeFileLinkMeta, resolveMarkdownFileLinkMeta, rewriteMarkdownFileUriHref, + shouldOpenMarkdownFileLinkInBrowserByDefault, shouldOpenMarkdownFileLinkInEditor, type MarkdownFileLinkMeta, } from "../markdown-links"; import { readLocalApi } from "../localApi"; -import { useAssetUrlState } from "../assets/assetUrls"; +import { useAssetUrlRefresh, useAssetUrlState } from "../assets/assetUrls"; import { cn } from "../lib/utils"; +import { useRemoteOpenResolution, type RemoteOpenMode } from "../remoteOpen"; import { useRightPanelStore } from "../rightPanelStore"; -import { useActiveEnvironmentId } from "../state/entities"; +import { readThreadShell, useProjects } from "../state/entities"; import { serverEnvironment } from "../state/server"; +import { shellEnvironment } from "../state/shell"; import { assetEnvironment } from "../state/assets"; import { usePreparedConnection } from "../state/session"; import { previewEnvironment } from "../state/preview"; import { useAtomCommand } from "../state/use-atom-command"; import { useAtomQueryRunner } from "../state/use-atom-query-runner"; import { projectEnvironment } from "../state/projects"; +import { threadEnvironment } from "../state/threads"; import { claimWorkspaceBasenameLookup, needsWorkspaceBasenameLookup, pickWorkspaceBasenameMatch, WORKSPACE_BASENAME_LOOKUP_LIMIT, } from "../workspaceBasenameLookup"; -import { useOpenChangeRequestLink } from "~/lib/openPullRequestLink"; +import { + findProjectForChangeRequest, + matchesLinkedPullRequestUrl, + parseChangeRequestUrl, + useOpenChangeRequestLink, +} from "~/lib/openPullRequestLink"; import { writeTextToClipboard } from "../hooks/useCopyToClipboard"; import { isPreviewSupportedInRuntime } from "../previewStateStore"; +import { resolvePathLinkTarget } from "../terminal-links"; import { isBrowserPreviewFile, openFileInPreview, @@ -115,6 +176,8 @@ interface ChatMarkdownProps { text: string; cwd: string | undefined; threadRef?: ScopedThreadRef | undefined; + /** Environment that owns non-thread markdown, such as a pull request panel. */ + environmentId?: EnvironmentId | undefined; onTaskListChange?: ((input: { markerOffset: number; checked: boolean }) => void) | undefined; isStreaming?: boolean; skills?: ReadonlyArray>; @@ -123,11 +186,111 @@ interface ChatMarkdownProps { lineBreaks?: boolean; /** Parse sanitized raw HTML instead of displaying its source text. */ parseRawHtml?: boolean; + /** Append a prompt that invokes a newly created artifact-template skill. */ + onUseArtifactTemplate?: ((template: CodexArtifactTemplate) => void) | undefined; + imageBaseDir?: string | undefined; + onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined; + extraRemarkPlugins?: NonNullable; +} + +export function canUseMarkdownFileShellActions( + environmentId: EnvironmentId | null, + remoteOpenMode: RemoteOpenMode, + isRemoteOpenResolved: boolean, +): boolean { + return environmentId !== null && isRemoteOpenResolved && remoteOpenMode === "local-exec"; +} + +export function hasMarkdownFilePrimaryAction(input: { + canOpenInEditor: boolean; + canOpenInBrowser: boolean; + canOpenInPanel: boolean; + canOpenMedia?: boolean; +}): boolean { + return ( + input.canOpenInEditor || + input.canOpenInBrowser || + input.canOpenInPanel || + input.canOpenMedia === true + ); +} + +export function shouldUseMarkdownFileBrowserPrimaryAction(input: { + iconPath: string; + canOpenInEditor: boolean; + canOpenInBrowser: boolean; + canOpenInPanel: boolean; +}): boolean { + return ( + input.canOpenInBrowser && + (shouldOpenMarkdownFileLinkInBrowserByDefault(input.iconPath) || + (!input.canOpenInEditor && !input.canOpenInPanel)) + ); } const EMPTY_MARKDOWN_SKILLS: ReadonlyArray> = []; +const EMPTY_REMARK_PLUGINS: NonNullable = []; + +const ARTIFACT_TEMPLATE_ICON_BY_KIND = { + document: FileTextIcon, + presentation: PresentationIcon, + spreadsheet: FileSpreadsheetIcon, + site: GlobeIcon, + "google-docs": FileTextIcon, + "google-slides": PresentationIcon, + "google-sheets": FileSpreadsheetIcon, + image: ImageIcon, + email: MailIcon, + slack: MessageSquareIcon, +} satisfies Record; + +function CodexArtifactTemplateCard(props: { + readonly template: CodexArtifactTemplate; + readonly onUse?: ((template: CodexArtifactTemplate) => void) | undefined; +}) { + const Icon = ARTIFACT_TEMPLATE_ICON_BY_KIND[props.template.artifactKind]; + const presentationLabel = codexArtifactTemplatePresentationLabel(props.template.artifactKind); + + return ( +

    +
    + + + + + + + + + {props.template.displayName} + + {presentationLabel} + +
    + {props.onUse ? ( + + ) : null} +
    + ); +} const CODE_FENCE_LANGUAGE_REGEX = /(?:^|\s)language-([^\s]+)/; +const WINDOWS_DRIVE_PATH_REGEX = /^[A-Za-z]:[\\/]/; const MAX_HIGHLIGHT_CACHE_ENTRIES = 500; const MAX_HIGHLIGHT_CACHE_MEMORY_BYTES = 50 * 1024 * 1024; @@ -181,27 +344,24 @@ export function orderedListGutterStyle( return { "--list-gutter": `${markerWidth + 1}ch` }; } -type MarkdownHtmlAstNode = { +type MarkdownImageHastNode = { type?: string; tagName?: string; properties?: Record; - children?: MarkdownHtmlAstNode[]; + children?: MarkdownImageHastNode[]; }; -/** Preserve Windows drive paths through the protocol allowlist in rehype-sanitize. */ -function rehypeNormalizeWindowsImageSrc() { - return (tree: MarkdownHtmlAstNode) => { - const visit = (node: MarkdownHtmlAstNode) => { +/** Carries authored image source metadata through the sanitizer to the image renderer. */ +function rehypePreserveImageSourceMeta() { + return (tree: MarkdownImageHastNode) => { + const visit = (node: MarkdownImageHastNode) => { const src = node.properties?.src; - if ( - node.type === "element" && - node.tagName === "img" && - typeof src === "string" && - /^[A-Za-z]:[\\/]/.test(src) - ) { + const title = node.properties?.title; + if (node.type === "element" && node.tagName === "img") { node.properties = { ...node.properties, - src: `file:///${src.replaceAll("\\", "/")}`, + ...(typeof src === "string" && isWindowsDrivePathHref(src) ? { dataLocalSrc: src } : {}), + ...(typeof title === "string" ? { dataMarkdownTitle: title } : {}), }; } node.children?.forEach(visit); @@ -218,6 +378,9 @@ const CHAT_MARKDOWN_SANITIZE_SCHEMA = { "*": (defaultSchema.attributes?.["*"] ?? []).filter((attribute) => attribute !== "title"), code: [...(defaultSchema.attributes?.code ?? []), "dataCodeMeta", "dataInlineCode"], blockquote: [...(defaultSchema.attributes?.blockquote ?? []), "dataAlert"], + div: [...(defaultSchema.attributes?.div ?? []), ...CODEX_ARTIFACT_TEMPLATE_HAST_PROPERTIES], + a: [...(defaultSchema.attributes?.a ?? []), "dataPullRequestAutolink"], + img: [...(defaultSchema.attributes?.img ?? []), "dataLocalSrc", "dataMarkdownTitle"], }, protocols: { ...defaultSchema.protocols, @@ -230,22 +393,24 @@ const CHAT_MARKDOWN_REMARK_PLUGINS = [ remarkGfm, remarkGithubAlerts, remarkNormalizeListItemIndentation, + remarkCodexDirectives, remarkPreserveCodeMeta, - remarkTagInlineCode, + remarkNormalizeLinksAndTagInlineCode, ] satisfies NonNullable; const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS = [ remarkGfm, remarkGithubAlerts, remarkNormalizeListItemIndentation, + remarkCodexDirectives, remarkBreaks, remarkPreserveCodeMeta, - remarkTagInlineCode, + remarkNormalizeLinksAndTagInlineCode, ] satisfies NonNullable; const CHAT_MARKDOWN_REHYPE_PLUGINS = [ rehypeRaw, - rehypeNormalizeWindowsImageSrc, + rehypePreserveImageSourceMeta, [rehypeSanitize, CHAT_MARKDOWN_SANITIZE_SCHEMA], ] satisfies NonNullable; @@ -326,6 +491,7 @@ function extractPreCodeMeta(node: unknown): string | undefined { type MarkdownAstNode = { type?: string; meta?: unknown; + url?: string; data?: { hProperties?: Record; }; @@ -352,15 +518,20 @@ function remarkPreserveCodeMeta() { } /** - * Fenced code also lands on the `code` component, and inline vs block is no - * longer distinguishable there once both render `` — so inline spans are - * tagged on the mdast, where the distinction still exists. Code inside a link - * label stays untagged: linkifying it would nest an anchor inside the link's - * anchor and steal its clicks. + * Preserve Windows drive links as allowed `file:` URLs before sanitization. + * The same traversal tags inline code while it can still be distinguished + * from fenced code. Code inside links stays untagged to avoid nested anchors. */ -function remarkTagInlineCode() { +function remarkNormalizeLinksAndTagInlineCode() { return (tree: MarkdownAstNode) => { const visit = (node: MarkdownAstNode, insideLink: boolean) => { + if ( + (node.type === "link" || node.type === "definition") && + typeof node.url === "string" && + WINDOWS_DRIVE_PATH_REGEX.test(node.url) + ) { + node.url = `file:///${node.url.replaceAll("\\", "/")}`; + } if (node.type === "inlineCode" && !insideLink) { node.data = { ...node.data, @@ -506,12 +677,7 @@ function MarkdownTable({ children, ...props }: React.ComponentProps<"table">) { className="chat-markdown-table-container" data-expanded={expanded ? "true" : "false"} > - + {children}
    @@ -857,14 +1023,20 @@ interface MarkdownFileLinkProps { copyMarkdown: string; theme: "light" | "dark"; threadRef?: ScopedThreadRef | undefined; - onOpen: (targetPath: string) => Promise>; + onOpen?: ((targetPath: string) => Promise>) | undefined; onOpenInPanel: (workspaceRelativePath: string, line: number | undefined) => void; + openInEditorMenuLabel: string; onOpenInBrowser?: (() => Promise>) | undefined; + onOpenMedia?: (() => void) | undefined; + onReveal?: (() => Promise>) | undefined; + /** Platform-specific menu label ("Reveal in Finder", ...); required for the + reveal item to show. */ + revealLabel?: string | undefined; className?: string | undefined; } -const MARKDOWN_FILE_LINK_CLASS_NAME = - "chat-markdown-file-link cursor-pointer transition-colors hover:bg-accent/70"; +const MARKDOWN_FILE_CHIP_CLASS_NAME = "chat-markdown-file-link"; +const MARKDOWN_FILE_LINK_CLASS_NAME = `${MARKDOWN_FILE_CHIP_CLASS_NAME} cursor-pointer transition-colors hover:bg-accent/70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/70`; function pathParentSegments(path: string): string[] { const normalized = path.replaceAll("\\", "/"); @@ -875,14 +1047,12 @@ function pathParentSegments(path: string): string[] { function buildFileLinkParentSuffixByPath(filePaths: ReadonlyArray): Map { const groups = new Map>(); for (const filePath of filePaths) { - const pathSegments = filePath - .replaceAll("\\", "/") - .split("/") - .filter((segment) => segment.length > 0); + const normalizedPath = filePath.replaceAll("\\", "/"); + const pathSegments = normalizedPath.split("/").filter((segment) => segment.length > 0); const basename = pathSegments[pathSegments.length - 1]; if (!basename) continue; const group = groups.get(basename) ?? new Set(); - group.add(filePath); + group.add(normalizedPath); groups.set(basename, group); } @@ -943,7 +1113,10 @@ function extractInlineCodeSpans(text: string): string[] { function normalizeMarkdownLinkHrefKey(href: string): string { const normalizedHref = normalizeMarkdownLinkDestination(href); - return rewriteMarkdownFileUriHref(normalizedHref) ?? normalizedHref; + const rewrittenHref = rewriteMarkdownFileUriHref(normalizedHref) ?? normalizedHref; + return WINDOWS_DRIVE_PATH_REGEX.test(rewrittenHref) + ? rewrittenHref.replaceAll("\\", "/") + : rewrittenHref; } const MARKDOWN_LINK_FAVICON_CLASS_NAME = "block size-full shrink-0 select-none"; @@ -977,59 +1150,287 @@ const MarkdownLinkFavicon = memo(function MarkdownLinkFavicon({ host }: { host: ); }); -const CHAT_MARKDOWN_IMAGE_SIZE_CLASS_NAME = - "h-auto w-auto max-h-[30rem] max-w-[min(100%,30rem)] object-contain"; +const CHAT_MARKDOWN_MEDIA_MAX_WIDTH_CLASS_NAME = "max-w-[min(100%,30rem)]"; +const CHAT_MARKDOWN_MEDIA_BOUNDS_CLASS_NAME = cn( + "max-h-[30rem]", + CHAT_MARKDOWN_MEDIA_MAX_WIDTH_CLASS_NAME, +); +const CHAT_MARKDOWN_MEDIA_LAYOUT_CLASS_NAME = "inline-block!"; +const CHAT_MARKDOWN_MEDIA_FRAME_CLASS_NAME = "rounded-lg border border-border/40"; +const CHAT_MARKDOWN_IMAGE_SIZE_CLASS_NAME = cn( + "h-auto w-auto object-contain", + CHAT_MARKDOWN_MEDIA_BOUNDS_CLASS_NAME, +); + +function markdownImageCopy(alt: string, src: string, title: string | undefined): string { + const escapedAlt = alt.replaceAll("\\", "\\\\").replaceAll("[", "\\[").replaceAll("]", "\\]"); + const titleSuffix = + title === undefined ? "" : ` "${title.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`; + return `![${escapedAlt}](${src}${titleSuffix})`; +} + +function authoredImageSizeStyle( + width: string | number | undefined, + height: string | number | undefined, +): CSSProperties | undefined { + const parsedWidth = Number(width); + const parsedHeight = Number(height); + const hasWidth = Number.isFinite(parsedWidth) && parsedWidth > 0; + const hasHeight = Number.isFinite(parsedHeight) && parsedHeight > 0; + if (hasWidth && hasHeight) { + return { + width: parsedWidth, + height: "auto", + aspectRatio: `${parsedWidth} / ${parsedHeight}`, + maxWidth: `min(100%, 30rem, ${(30 * parsedWidth) / parsedHeight}rem)`, + }; + } + if (hasWidth) return { maxWidth: `min(100%, 30rem, ${parsedWidth}px)` }; + if (hasHeight) return { maxHeight: `min(30rem, ${parsedHeight}px)` }; + return undefined; +} -// block! outranks the unlayered `.chat-markdown img { display: inline-block }` -// rule, keeping workspace images on the same block layout as their placeholder. const CHAT_MARKDOWN_WORKSPACE_IMAGE_CLASS_NAME = cn( CHAT_MARKDOWN_IMAGE_SIZE_CLASS_NAME, - "my-1 block! rounded-lg border border-border/40", + CHAT_MARKDOWN_MEDIA_LAYOUT_CLASS_NAME, + CHAT_MARKDOWN_MEDIA_FRAME_CLASS_NAME, ); +const MarkdownLinkContext = React.createContext(false); + +function expandableMarkdownImageProps( + onImageExpand: ((preview: ExpandedImagePreview) => void) | undefined, + src: string, + alt: string, + originalUrl?: string, + actionsSource?: MediaActionSource, +) { + if (!onImageExpand) return {}; + const previewName = alt.trim() || "image"; + const expand = (event: ReactMouseEvent | ReactKeyboardEvent) => { + if (event.currentTarget.closest("a")) return; + event.preventDefault(); + event.stopPropagation(); + onImageExpand({ + images: [ + { + src, + name: previewName, + ...(originalUrl ? { originalUrl } : {}), + ...(actionsSource ? { actionsSource } : {}), + }, + ], + index: 0, + }); + }; + return { + role: "button" as const, + tabIndex: 0, + "aria-label": `Preview ${previewName}`, + onClick: expand, + onKeyDown: (event: ReactKeyboardEvent) => { + if (event.key === "Enter" || event.key === " ") expand(event); + }, + }; +} -function ChatMarkdownImageFallback(props: { readonly alt: string }) { - return ( - - - {props.alt.length > 0 ? `Image unavailable · ${props.alt}` : "Image unavailable"} +function ChatMarkdownImageFallback(props: { + readonly alt: string; + readonly copyMarkdown?: string | undefined; + readonly kind?: "image" | "video"; + readonly actionsSource?: MediaActionSource; +}) { + const label = props.kind === "video" ? "Video unavailable" : "Image unavailable"; + const content = ( + + + + {props.alt.length > 0 ? `${label} · ${props.alt}` : label} + ); + return props.actionsSource ? ( + {content} + ) : ( + content + ); } -/** Markdown images whose src is a workspace file path load through a signed asset URL. */ -const ChatMarkdownWorkspaceImage = memo(function ChatMarkdownWorkspaceImage(props: { - readonly threadRef: ScopedThreadRef; - readonly path: string; +function ChatMarkdownVideo(props: { + readonly src: string | null; readonly alt: string; + readonly copyMarkdown: string | undefined; + readonly originalUrl?: string | undefined; + readonly sourceFailed?: boolean | undefined; + readonly style?: CSSProperties | undefined; + readonly mediaIdentity?: string | undefined; + readonly actionsSource?: MediaActionSource | undefined; + readonly onRetry?: (() => Promise) | undefined; + readonly onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined; }) { - const assetUrl = useAssetUrlState(props.threadRef.environmentId, { - _tag: "workspace-file", - threadId: props.threadRef.threadId, - path: props.path, - }); + return ( + { + props.onImageExpand?.({ + images: [ + { + src, + name: props.alt || "video", + type: "video", + autoPlay: false, + ...(props.originalUrl ? { originalUrl: props.originalUrl } : {}), + ...(props.actionsSource + ? { actionsSource: { ...props.actionsSource, src } } + : {}), + }, + ], + index: 0, + }); + } + : undefined + } + /> + ); +} + +/** Environment-hosted media loads through an exact-file signed asset URL. */ +export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props: { + readonly environmentId: EnvironmentId; + readonly resource: Extract< + AssetResource, + { readonly _tag: "attachment" | "workspace-file" | "media-file" } + >; + readonly kind?: "image" | "video"; + readonly alt: string; + readonly copyMarkdown?: string; + readonly srcFragment?: string; + readonly style?: CSSProperties | undefined; + readonly workspaceRoot?: string | undefined; + readonly onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined; +}) { + const assetUrl = useAssetUrlState(props.environmentId, props.resource); + const refreshAssetUrl = useAssetUrlRefresh(props.environmentId, props.resource); const [failedUrl, setFailedUrl] = useState(null); + const resource = props.resource; + const path = + resource._tag === "media-file" + ? resource.path + : resource._tag === "workspace-file" && props.workspaceRoot + ? `${props.workspaceRoot.replace(/[\\/]+$/, "")}/${resource.path}` + : undefined; + const reference = path ? mediaFileReference(path, props.workspaceRoot) : undefined; + const relativePath = reference?.relativePath; + const src = assetUrl._tag === "Success" ? assetUrl.url + (props.srcFragment ?? "") : null; + const actionsSource: MediaActionSource = { + kind: props.kind ?? "image", + name: props.alt || (props.kind ?? "image"), + src, + asset: { environmentId: props.environmentId, resource }, + ...(reference ? { reference } : {}), + ...(relativePath && resource._tag !== "attachment" + ? { + onOpenFile: () => + useRightPanelStore + .getState() + .openFile( + { environmentId: props.environmentId, threadId: resource.threadId }, + relativePath, + ), + } + : {}), + }; + + if (props.kind === "video") { + return ( + + ); + } if (assetUrl._tag === "Failure" || (assetUrl._tag === "Success" && failedUrl === assetUrl.url)) { - return ; + return ( + + ); } if (assetUrl._tag !== "Success") { return ( - + + + ); } return ( - {props.alt} setFailedUrl(assetUrl.url)} - /> + + {props.alt} setFailedUrl(assetUrl.url)} + /> + ); }); @@ -1210,10 +1611,17 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ threadRef, onOpen, onOpenInPanel, + openInEditorMenuLabel, onOpenInBrowser, + onOpenMedia, + onReveal, + revealLabel, className, }: MarkdownFileLinkProps) { const handleOpenInEditor = useCallback(() => { + if (!onOpen) { + return; + } void (async () => { try { const result = await onOpen(targetPath); @@ -1249,12 +1657,16 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ }, [onOpen, targetPath]); const handleOpenInFilePreview = useCallback(() => { - if (!threadRef || !workspaceRelativePath) { - handleOpenInEditor(); + if (threadRef && workspaceRelativePath) { + onOpenInPanel(workspaceRelativePath, line); + return; + } + if (onOpenMedia) { + onOpenMedia(); return; } - onOpenInPanel(workspaceRelativePath, line); - }, [handleOpenInEditor, line, onOpenInPanel, threadRef, workspaceRelativePath]); + handleOpenInEditor(); + }, [handleOpenInEditor, line, onOpenInPanel, onOpenMedia, threadRef, workspaceRelativePath]); const handleOpenInBrowser = useCallback(() => { if (!onOpenInBrowser) { @@ -1294,6 +1706,44 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ })(); }, [onOpenInBrowser, targetPath]); + const handleRevealInFileManager = useCallback(() => { + if (!onReveal) { + return; + } + void (async () => { + try { + const result = await onReveal(); + if (result._tag === "Success" || isAtomCommandInterrupted(result)) { + return; + } + reportMarkdownActionFailure( + { operation: "reveal-file-in-file-manager", target: targetPath }, + result.cause, + ); + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Unable to reveal file", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } catch (cause) { + reportMarkdownActionFailure( + { operation: "reveal-file-in-file-manager", target: targetPath }, + cause, + ); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Unable to reveal file", + description: cause instanceof Error ? cause.message : "An error occurred.", + }), + ); + } + })(); + }, [onReveal, targetPath]); + const handleCopy = useCallback( (value: string, title: string) => { if (typeof window === "undefined" || !navigator.clipboard?.writeText) { @@ -1333,27 +1783,30 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ [targetPath], ); - const handleContextMenu = useCallback( - async (event: ReactMouseEvent) => { - event.preventDefault(); - event.stopPropagation(); - + const showFileContextMenu = useCallback( + async (position: { x: number; y: number }) => { const api = readLocalApi(); if (!api) return; try { const clicked = await api.contextMenu.show( [ - { id: "open", label: "Open in editor" }, + ...(onOpenMedia ? ([{ id: "preview-media", label: "Preview media" }] as const) : []), + ...(onOpen ? ([{ id: "open", label: openInEditorMenuLabel }] as const) : []), ...(onOpenInBrowser ? ([{ id: "open-in-browser", label: "Open in integrated browser" }] as const) : []), + ...(onReveal && revealLabel ? ([{ id: "reveal", label: revealLabel }] as const) : []), { id: "copy-relative", label: "Copy relative path" }, { id: "copy-full", label: "Copy full path" }, ] as const, - { x: event.clientX, y: event.clientY }, + position, ); + if (clicked === "preview-media") { + onOpenMedia?.(); + return; + } if (clicked === "open") { handleOpenInEditor(); return; @@ -1362,6 +1815,10 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ handleOpenInBrowser(); return; } + if (clicked === "reveal") { + handleRevealInFileManager(); + return; + } if (clicked === "copy-relative") { handleCopy(displayPath, "Relative path"); return; @@ -1376,34 +1833,102 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ ); } }, - [displayPath, handleCopy, handleOpenInBrowser, handleOpenInEditor, onOpenInBrowser, targetPath], + [ + displayPath, + handleCopy, + handleOpenInBrowser, + handleOpenInEditor, + handleRevealInFileManager, + onOpenInBrowser, + onOpenMedia, + onOpen, + onReveal, + openInEditorMenuLabel, + revealLabel, + targetPath, + ], + ); + + const handleContextMenu = useCallback( + (event: ReactMouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + const position = + event.clientX === 0 && event.clientY === 0 + ? (() => { + const bounds = event.currentTarget.getBoundingClientRect(); + return { x: bounds.left, y: bounds.bottom }; + })() + : { x: event.clientX, y: event.clientY }; + void showFileContextMenu(position); + }, + [showFileContextMenu], ); + const canOpenInEditor = onOpen !== undefined; + const canOpenInBrowser = onOpenInBrowser !== undefined; + const canOpenInPanel = threadRef !== undefined && Boolean(workspaceRelativePath); + const hasPrimaryAction = hasMarkdownFilePrimaryAction({ + canOpenInEditor, + canOpenInBrowser, + canOpenInPanel, + canOpenMedia: onOpenMedia !== undefined, + }); + const useBrowserPrimaryAction = shouldUseMarkdownFileBrowserPrimaryAction({ + iconPath, + canOpenInEditor, + canOpenInBrowser, + canOpenInPanel, + }); + return ( { - event.preventDefault(); - event.stopPropagation(); - if (shouldOpenMarkdownFileLinkInEditor(event)) { - handleOpenInEditor(); - return; - } - if (onOpenInBrowser) { - handleOpenInBrowser(); - return; - } - handleOpenInFilePreview(); - }} - onContextMenu={handleContextMenu} - > - -
    + hasPrimaryAction ? ( + { + event.preventDefault(); + event.stopPropagation(); + if (onOpen && shouldOpenMarkdownFileLinkInEditor(event)) { + handleOpenInEditor(); + return; + } + if (useBrowserPrimaryAction) { + handleOpenInBrowser(); + return; + } + handleOpenInFilePreview(); + }} + onContextMenu={handleContextMenu} + > + + + ) : ( + + ) } /> (null); + const expandMedia = onImageExpand ?? setLocalMediaPreview; + const mediaRequestId = useRef(0); + useEffect(() => { + setLocalMediaPreview(null); + return () => { + mediaRequestId.current += 1; + }; + }, [threadRef?.environmentId, threadRef?.threadId, explicitEnvironmentId, cwd, imageBaseDir]); const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { reportFailure: false, + refresh: true, }); const searchProjectEntries = useAtomQueryRunner(projectEnvironment.searchEntries, { reportFailure: false, @@ -1463,12 +2007,87 @@ function ChatMarkdown({ const openPreview = useAtomCommand(previewEnvironment.open, { reportFailure: false, }); - const preparedConnection = usePreparedConnection(threadRef?.environmentId ?? null); - const environmentId = useActiveEnvironmentId(); - const serverConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId)); - const openInPreferredEditor = useOpenInPreferredEditor( + const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { + reportFailure: false, + }); + const environmentId = threadRef?.environmentId ?? explicitEnvironmentId ?? null; + const remoteOpen = useRemoteOpenResolution(environmentId); + const canUseShellActions = canUseMarkdownFileShellActions( environmentId, - serverConfig?.availableEditors ?? [], + remoteOpen.state.mode, + remoteOpen.isResolved, + ); + const preparedConnection = usePreparedConnection(environmentId); + const openMarkdownMedia = useCallback( + (source: string, resolvedFilePath?: string) => { + const requestId = ++mediaRequestId.current; + void resolveMarkdownMediaPreview({ + source, + resolvedFilePath, + cwd, + threadRef, + httpBaseUrl: + preparedConnection._tag === "Some" ? preparedConnection.value.httpBaseUrl : undefined, + createAssetUrl, + onOpenFile: threadRef + ? (path) => useRightPanelStore.getState().openFile(threadRef, path) + : undefined, + }).then( + (preview) => { + if (preview && mediaRequestId.current === requestId) expandMedia(preview); + }, + (error: unknown) => { + if (mediaRequestId.current !== requestId) return; + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Media unavailable", + description: + error instanceof Error + ? error.message + : "The file could not be loaded. It may have been moved or deleted.", + }), + ); + }, + ); + }, + [createAssetUrl, cwd, expandMedia, preparedConnection, threadRef], + ); + const serverConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId)); + const threadServerConfig = useAtomValue( + serverEnvironment.configValueAtom(threadRef?.environmentId ?? environmentId), + ); + const projects = useProjects(); + const availableEditors = serverConfig?.availableEditors ?? []; + const [preferredEditor] = usePreferredEditor(availableEditors); + const preferredEditorMenuLabel = openInEditorMenuLabel(preferredEditor); + const openInPreferredEditor = useOpenInPreferredEditor(environmentId, availableEditors); + const openInEditor = useAtomCommand(shellEnvironment.openInEditor, { + reportFailure: false, + }); + const revealInFileManagerLabel = + environmentId !== null && + serverConfig?.shellRevealInFileManager === true && + serverConfig.availableEditors.includes("file-manager") + ? serverConfig.shellRevealInFileManagerKind === undefined + ? revealInFileExplorerLabelForOs(serverConfig.environment.platform.os) + : revealInFileExplorerLabelForKind(serverConfig.shellRevealInFileManagerKind) + : undefined; + const revealFileInFileManager = useCallback( + (filePath: string) => { + if (environmentId === null) { + return Promise.resolve( + AsyncResult.failure( + Cause.fail(new PreferredEditorEnvironmentRequiredError({ targetPath: filePath })), + ), + ); + } + return openInEditor({ + environmentId, + input: { cwd: filePath, editor: "file-manager", reveal: true }, + }); + }, + [environmentId, openInEditor], ); const diffThemeName = resolveDiffThemeName(resolvedTheme); const markdownFileLinkMetaByHref = useMemo(() => { @@ -1476,7 +2095,7 @@ function ChatMarkdown({ string, NonNullable> >(); - for (const href of extractMarkdownLinkHrefs(text)) { + for (const href of extractMarkdownLinkHrefs(renderCodexFileCitationsAsMarkdown(text))) { const normalizedHref = normalizeMarkdownLinkHrefKey(href); if (metaByHref.has(normalizedHref)) continue; const meta = resolveMarkdownFileLinkMeta(normalizedHref, cwd); @@ -1505,6 +2124,7 @@ function ChatMarkdown({ return buildFileLinkParentSuffixByPath(filePaths); }, [inlineCodeFileLinkMetaByText, markdownFileLinkMetaByHref]); const markdownUrlTransform = useCallback((href: string) => { + if (isWindowsDrivePathHref(href)) return href; return rewriteMarkdownFileUriHref(href) ?? defaultUrlTransform(href); }, []); // Re-emit highlighted content as markdown so copying out of the rendered @@ -1519,6 +2139,54 @@ function ChatMarkdown({ event.clipboardData.setData("text/html", payload.html); }, []); const openChangeRequestLink = useOpenChangeRequestLink(threadRef); + const resolveThreadPullRequest = useCallback( + (href: string): ThreadLinkedPullRequest | null => { + if ( + threadRef === undefined || + readThreadShell(threadRef) === null || + threadServerConfig?.environment.capabilities.threadPullRequestLinking !== true + ) { + return null; + } + const parsed = parseChangeRequestUrl(href); + if (parsed === null) return null; + const project = findProjectForChangeRequest( + projects.filter((candidate) => candidate.environmentId === threadRef.environmentId), + parsed, + ); + if (project === undefined) return null; + return { + projectId: project.id, + repository: project.repositoryIdentity?.displayName ?? parsed.repository, + number: parsed.number, + url: href, + }; + }, + [projects, threadRef, threadServerConfig], + ); + const updateThreadPullRequestLink = useCallback( + async (href: string, linked: boolean) => { + if (threadRef === undefined) return; + const linkedPullRequest = linked ? resolveThreadPullRequest(href) : null; + if (linked && linkedPullRequest === null) { + throw new Error("The pull request is not available in this environment."); + } + if (!linked) { + const currentPullRequest = readThreadShell(threadRef)?.linkedPullRequest; + if (currentPullRequest == null || !matchesLinkedPullRequestUrl(currentPullRequest, href)) { + return; + } + } + const result = await updateThreadMetadata({ + environmentId: threadRef.environmentId, + input: { threadId: threadRef.threadId, linkedPullRequest }, + }); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + throw squashAtomCommandFailure(result); + } + }, + [resolveThreadPullRequest, threadRef, updateThreadMetadata], + ); const openExternalLinkInPreview = useCallback( (url: string) => { if (!threadRef) { @@ -1562,6 +2230,26 @@ function ChatMarkdown({ }, [createAssetUrl, openPreview, preparedConnection, threadRef], ); + const findWorkspaceBasenameMatch = useCallback( + async (workspaceRelativePath: string) => { + if (!cwd || environmentId === null || !needsWorkspaceBasenameLookup(workspaceRelativePath)) { + return null; + } + const result = await searchProjectEntries({ + environmentId, + input: { + cwd, + query: workspaceRelativePath, + limit: WORKSPACE_BASENAME_LOOKUP_LIMIT, + kind: "file", + }, + }); + return result._tag === "Success" + ? pickWorkspaceBasenameMatch(workspaceRelativePath, result.value.entries) + : null; + }, + [cwd, environmentId, searchProjectEntries], + ); // A bare filename resolves to the workspace root, which is rarely where the // file is, so ask the index before opening. const openFileInPanel = useCallback( @@ -1577,24 +2265,23 @@ function ChatMarkdown({ return; } void (async () => { - const result = await searchProjectEntries({ - environmentId: threadRef.environmentId, - input: { - cwd, - query: workspaceRelativePath, - limit: WORKSPACE_BASENAME_LOOKUP_LIMIT, - kind: "file", - }, - }); - const match = - result._tag === "Success" - ? pickWorkspaceBasenameMatch(workspaceRelativePath, result.value.entries) - : null; + const match = await findWorkspaceBasenameMatch(workspaceRelativePath); if (!isLatestLookup()) return; openAt(match ?? workspaceRelativePath); })(); }, - [cwd, searchProjectEntries, threadRef], + [cwd, findWorkspaceBasenameMatch, threadRef], + ); + const revealMarkdownFileInFileManager = useCallback( + async (fileLinkMeta: MarkdownFileLinkMeta) => { + const workspaceRelativePath = fileLinkMeta.workspaceRelativePath; + const match = workspaceRelativePath + ? await findWorkspaceBasenameMatch(workspaceRelativePath) + : null; + const filePath = match && cwd ? resolvePathLinkTarget(match, cwd) : fileLinkMeta.filePath; + return revealFileInFileManager(filePath); + }, + [cwd, findWorkspaceBasenameMatch, revealFileInFileManager], ); /* eslint-disable react/no-unstable-nested-components -- ReactMarkdown requires component * renderers that close over this message's metadata. useMemo keeps them stable until that @@ -1604,8 +2291,11 @@ function ChatMarkdown({ fileLinkMeta: MarkdownFileLinkMeta, copyMarkdown: string, className?: string, + mediaSource?: string, ) => { - const parentSuffix = fileLinkParentSuffixByPath.get(fileLinkMeta.filePath); + const parentSuffix = fileLinkParentSuffixByPath.get( + fileLinkMeta.filePath.replaceAll("\\", "/"), + ); const labelParts = [fileLinkMeta.basename]; if (typeof parentSuffix === "string" && parentSuffix.length > 0) { labelParts.push(parentSuffix); @@ -1615,6 +2305,11 @@ function ChatMarkdown({ `L${fileLinkMeta.line}${fileLinkMeta.column ? `:C${fileLinkMeta.column}` : ""}`, ); } + const mediaPath = mediaSource ?? fileLinkMeta.filePath; + const canPreviewMedia = + mediaMimeTypeFromExtension( + fileLinkMeta.basename.slice(fileLinkMeta.basename.lastIndexOf(".")), + ) !== null; return ( openMarkdownMedia(mediaPath, fileLinkMeta.filePath) + : undefined + } + openInEditorMenuLabel={preferredEditorMenuLabel} + onReveal={ + canUseShellActions && revealInFileManagerLabel !== undefined + ? () => revealMarkdownFileInFileManager(fileLinkMeta) + : undefined + } + revealLabel={revealInFileManagerLabel} onOpenInBrowser={ threadRef && isPreviewSupportedInRuntime() && @@ -1643,6 +2350,15 @@ function ChatMarkdown({ }; return { + div({ node, children, ...props }) { + const artifactTemplate = artifactTemplateFromHastProperties(node?.properties); + if (artifactTemplate) { + return ( + + ); + } + return
    {children}
    ; + }, p({ node: _node, children, ...props }) { return

    {renderSkillInlineMarkdownChildren(children, skills)}

    ; }, @@ -1722,12 +2438,25 @@ function ChatMarkdown({ : null; if (!fileLinkMeta) { const faviconHost = resolveExternalWebLinkHost(href); + const pullRequestAutolink = String( + (props as Record)["data-pull-request-autolink"] ?? "", + ); + const pullRequestCopy = + pullRequestAutolink === "commit" + ? /\/commit\/([0-9a-f]{40})$/iu.exec(href ?? "")?.[1] + : pullRequestAutolink === "reference" + ? plainHastText(node) + : undefined; + const isPullRequestAutolink = pullRequestCopy !== undefined; const isSameDocumentLink = href?.startsWith("#") ?? false; const onClick = props.onClick; const canOpenInPreview = Boolean(threadRef) && isPreviewSupportedInRuntime(); + const linkChildren = {children}; const link = ( api.contextMenu.show(items, position), openInPreview: async (target) => { @@ -1765,18 +2520,35 @@ function ChatMarkdown({ }, openExternal: (target) => api.shell.openExternal(target), copyLink: (target) => writeTextToClipboard(target, "link"), + updateThreadLink: updateThreadPullRequestLink, reportFailure: (operation, cause) => { reportMarkdownActionFailure({ operation, target: href }, cause); + if ( + operation === "link-pull-request-to-thread" || + operation === "unlink-pull-request-from-thread" + ) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: + operation === "link-pull-request-to-thread" + ? "Unable to link pull request" + : "Unable to unlink pull request", + description: + cause instanceof Error ? cause.message : "The request failed.", + }), + ); + } }, }); }} > - {faviconHost && hastHasText(node) ? ( + {faviconHost && hastHasText(node) && !isPullRequestAutolink ? ( - {children} + {linkChildren} ) : ( - children + linkChildren )} ); @@ -1800,6 +2572,7 @@ function ChatMarkdown({ fileLinkMeta, `[${fileLinkMeta.basename}](${normalizedHref})`, props.className, + normalizedHref, ); }, code({ node, children, className, ...props }) { @@ -1809,7 +2582,12 @@ function ChatMarkdown({ inlineCodeFileLinkMetaByText.get(codeText.trim()) ?? resolveInlineCodeFileLinkMeta(codeText, cwd); if (fileLinkMeta) { - return fileLinkChip(fileLinkMeta, `\`${codeText}\``); + return fileLinkChip( + fileLinkMeta, + `\`${codeText}\``, + undefined, + inlineCodeFilePathCandidate(codeText) ?? codeText.trim(), + ); } } return ( @@ -1818,31 +2596,89 @@ function ChatMarkdown({
    ); }, - img({ node: _node, title: _title, src, alt, ...props }) { - const srcString = typeof src === "string" ? normalizeMarkdownLinkDestination(src) : ""; + img: function MarkdownImage({ node, title, src, alt, ...props }) { + const imageExpand = use(MarkdownLinkContext) ? undefined : expandMedia; + const localSrc = node?.properties?.dataLocalSrc; + const markdownTitle = node?.properties?.dataMarkdownTitle; + const authoredSrc = typeof localSrc === "string" ? localSrc : src; + const authoredTitle = typeof markdownTitle === "string" ? markdownTitle : title; + const srcString = + typeof authoredSrc === "string" ? normalizeMarkdownLinkDestination(authoredSrc) : ""; + const classifiedSrc = + typeof localSrc === "string" ? srcString.replaceAll("\\", "/") : srcString; const altText = alt ?? ""; - const imageSource = classifyMarkdownImageSource(srcString, cwd); + const copyMarkdown = markdownImageCopy(altText, srcString, authoredTitle); + const authoredSizeStyle = authoredImageSizeStyle(props.width, props.height); + const imageSource = classifyMarkdownImageSource(classifiedSrc, imageBaseDir ?? cwd); + const kind = mediaKindFromPath(classifiedSrc) ?? "image"; if (imageSource._tag === "Direct") { + const mediaSrc = resolveProtocolRelativeMediaUrl(imageSource.uri); + const originalUrl = + resolveExternalWebLinkHost(imageSource.uri) !== null ? imageSource.uri : undefined; + const reference = mediaUrlReference(imageSource.uri); + const actionsSource: MediaActionSource = { + kind, + name: altText || kind, + src: mediaSrc, + ...(reference ? { reference } : {}), + }; + if (kind === "video") { + return ( + + ); + } return ( - {altText} + + {altText} + ); } if (imageSource._tag === "WorkspaceFile" && threadRef) { return ( - ); } - return ; + return ; }, table({ node: _node, ...props }) { return ; @@ -1880,24 +2716,44 @@ function ChatMarkdown({ }, }; }, [ + canUseShellActions, cwd, diffThemeName, fileLinkParentSuffixByPath, inlineCodeFileLinkMetaByText, + imageBaseDir, isStreaming, markdownFileLinkMetaByHref, onTaskListChange, + onUseArtifactTemplate, + onImageExpand, + expandMedia, + openMarkdownMedia, openFileInPanel, openInPreferredEditor, + openChangeRequestLink, openExternalLinkInPreview, openMarkdownFileInPreview, + preferredEditorMenuLabel, + resolveThreadPullRequest, resolvedTheme, + revealMarkdownFileInFileManager, + revealInFileManagerLabel, skills, text, threadRef, + updateThreadPullRequestLink, ]); /* eslint-enable react/no-unstable-nested-components */ + const remarkPlugins = useMemo( + () => [ + ...(lineBreaks ? CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS : CHAT_MARKDOWN_REMARK_PLUGINS), + ...extraRemarkPlugins, + ], + [extraRemarkPlugins, lineBreaks], + ); + // react-markdown converts unparsed HTML nodes to text when skipHtml is false. // Keep that behavior explicit because literal mode depends on escaping the // complete source token instead of dropping it from the rendered message. @@ -1910,9 +2766,7 @@ function ChatMarkdown({ onCopy={handleCopy} > {text} + {localMediaPreview ? ( + setLocalMediaPreview(null)} + /> + ) : null}
    ); } diff --git a/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx b/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx index 0b7838afa86e..172793bacb0c 100644 --- a/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx +++ b/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx @@ -4,16 +4,17 @@ import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; const testState = vi.hoisted(() => ({ resources: [] as Array, - assetState: "success" as "success" | "loading", + assetState: "success" as "success" | "loading" | "failure", })); vi.mock("@effect/atom-react", () => ({ useAtomValue: () => null })); vi.mock("../assets/assetUrls", () => ({ + useAssetUrlRefresh: () => vi.fn(), useAssetUrlState: (_environmentId: unknown, resource: unknown) => { testState.resources.push(resource); - return testState.assetState === "loading" - ? { _tag: "Loading" } - : { _tag: "Success", url: "https://signed.test/workspace-image.svg" }; + if (testState.assetState === "loading") return { _tag: "Loading" }; + if (testState.assetState === "failure") return { _tag: "Failure" }; + return { _tag: "Success", url: "https://signed.test/workspace-image.svg" }; }, })); vi.mock("../hooks/useTheme", () => ({ useTheme: () => ({ resolvedTheme: "dark" }) })); @@ -24,12 +25,25 @@ vi.mock("../state/session", async (importOriginal) => ({ usePreparedConnection: () => ({ _tag: "Loading" }), })); vi.mock("../state/entities", () => ({ - useActiveEnvironmentId: () => EnvironmentId.make("env-windows"), + readThreadShell: () => null, + useProjects: () => [], +})); +vi.mock("../remoteOpen", () => ({ + useRemoteOpenResolution: () => ({ state: { mode: "local-exec" }, isResolved: true }), +})); +vi.mock("../editorPreferences", () => ({ + useOpenInPreferredEditor: () => vi.fn(), + usePreferredEditor: () => [null, vi.fn()], +})); +vi.mock("~/lib/openPullRequestLink", () => ({ + findProjectForChangeRequest: () => undefined, + matchesLinkedPullRequestUrl: () => false, + parseChangeRequestUrl: () => null, + useOpenChangeRequestLink: () => vi.fn(), })); -vi.mock("../editorPreferences", () => ({ useOpenInPreferredEditor: () => vi.fn() })); -vi.mock("~/lib/openPullRequestLink", () => ({ useOpenChangeRequestLink: () => vi.fn() })); import ChatMarkdown from "./ChatMarkdown"; +import { FileMarkdownPreview } from "./files/FileMarkdownPreview"; const threadRef = { environmentId: EnvironmentId.make("env-windows"), @@ -46,12 +60,60 @@ function renderWithoutThread(markdown: string): string { return renderToStaticMarkup(); } +function renderFilePreview(cwd: string, relativePath: string): string { + return renderToStaticMarkup( + , + ); +} + +function copiedMarkdownFrom(html: string): string { + const copy = /data-markdown-copy="([^"]*)"/.exec(html)?.[1]?.replaceAll(""", '"'); + expect(copy).toBeDefined(); + return copy ?? ""; +} + +function firstInlineStyle(html: string): Record { + const style = /style="([^"]+)"/.exec(html)?.[1]; + expect(style).toBeDefined(); + return Object.fromEntries( + (style ?? "").split(";").map((declaration) => { + const separator = declaration.indexOf(":"); + return [declaration.slice(0, separator), declaration.slice(separator + 1)]; + }), + ); +} + describe("ChatMarkdown workspace images", () => { beforeEach(() => { testState.resources = []; testState.assetState = "success"; }); + it.each([ + ["/workspace/project", "docs/README.md", "/workspace/project/docs/images/diagram.png"], + [ + "C:\\Users\\shawn\\project", + "docs\\README.md", + "C:\\Users\\shawn\\project\\docs\\images\\diagram.png", + ], + ["/workspace/project", "README.md", "/workspace/project/images/diagram.png"], + ])("resolves images beside a nested file in %s", (cwd, relativePath, expectedPath) => { + renderFilePreview(cwd, relativePath); + + expect(testState.resources).toEqual([ + { + _tag: "media-file", + threadId: threadRef.threadId, + path: expectedPath, + }, + ]); + }); + it("loads every Windows workspace path form through a signed asset URL", () => { const imagePath = "C:/Users/shawn/project/.t3/workspace-image.svg"; const html = render( @@ -65,14 +127,14 @@ describe("ChatMarkdown workspace images", () => { expect(testState.resources).toEqual([ { - _tag: "workspace-file", + _tag: "media-file", threadId: threadRef.threadId, path: "C:\\Users\\shawn\\project\\.t3\\workspace-image.svg", }, - { _tag: "workspace-file", threadId: threadRef.threadId, path: imagePath }, - { _tag: "workspace-file", threadId: threadRef.threadId, path: imagePath }, + { _tag: "media-file", threadId: threadRef.threadId, path: imagePath }, + { _tag: "media-file", threadId: threadRef.threadId, path: imagePath }, { - _tag: "workspace-file", + _tag: "media-file", threadId: threadRef.threadId, path: "\\\\server\\share\\workspace-image.svg", }, @@ -88,7 +150,7 @@ describe("ChatMarkdown workspace images", () => { expect(testState.resources).toEqual([ { - _tag: "workspace-file", + _tag: "media-file", threadId: threadRef.threadId, path: "D:/screens/workspace-image.svg", }, @@ -96,13 +158,116 @@ describe("ChatMarkdown workspace images", () => { expect(html).toContain("https://signed.test/workspace-image.svg"); }); - it("uses a static placeholder while a signed asset URL loads", () => { + it("keeps a tall image placeholder and loaded image at the same proportional bounds", () => { + const markdown = 'sized'; + const loadedStyle = firstInlineStyle(render(markdown)); + testState.assetState = "loading"; + const loadingStyle = firstInlineStyle(render(markdown)); + + expect(loadedStyle).toMatchObject({ + width: "96px", + height: "auto", + "aspect-ratio": "96 / 128", + "max-width": "min(100%, 30rem, 22.5rem)", + }); + expect(loadingStyle).toEqual(loadedStyle); + }); + + it.each([ + ["width", "max-width", "min(100%, 30rem, 300px)"], + ["height", "max-height", "min(30rem, 300px)"], + ])("treats a lone authored %s as a cap", (axis, constraint, expectedValue) => { + const markdown = `sized`; + const loadedStyle = firstInlineStyle(render(markdown)); + + expect(loadedStyle).not.toHaveProperty(axis); + expect(loadedStyle).toHaveProperty(constraint, expectedValue); + }); + + it("keeps all images baseline-aligned and workspace images inline", () => { + const html = render( + "![remote](https://example.com/badge.svg) ![workspace](.t3/workspace-image.svg)", + ); + const classNames = Array.from(html.matchAll(/]*class="([^"]*)"/g), (match) => + match[1]?.split(" "), + ); + + expect(classNames).toHaveLength(2); + expect(classNames[1]).toContain("inline-block!"); + + const centeredHtml = render( + '

    logo

    ', + ); + const centeredClassName = /]*class="([^"]*)"/.exec(centeredHtml)?.[1]; + + expect(centeredClassName?.split(" ")).toContain("inline-block!"); + }); + + it("retains an authored SVG fragment on the signed URL", () => { + const html = render("![logo](icons.svg#logo)"); + + expect(html).toContain('src="https://signed.test/workspace-image.svg#logo"'); + }); + + it.each(["success", "loading", "failure", "no-thread"] as const)( + "copies the authored workspace source (%s)", + (scenario) => { + if (scenario === "no-thread") { + const html = renderWithoutThread("![diagram](images/diagram.png)"); + expect(copiedMarkdownFrom(html)).toBe("![diagram](images/diagram.png)"); + return; + } + + testState.assetState = scenario; + const html = render("![diagram](images/diagram.png#preview)"); + + expect(copiedMarkdownFrom(html)).toBe("![diagram](images/diagram.png#preview)"); + }, + ); + + it("copies an authored title with a workspace image", () => { + const html = render('![logo](images/logo.svg "My Title")'); + + expect(copiedMarkdownFrom(html)).toBe('![logo](images/logo.svg "My Title")'); + }); + + it("escapes double quotes in an authored image title", () => { + const html = render(`![logo](images/logo.svg 'My "Title"')`); + + expect(copiedMarkdownFrom(html)).toBe('![logo](images/logo.svg "My \\"Title\\"")'); + }); + + it("escapes a closing bracket in authored image alt text", () => { + const markdown = String.raw`![build\] badge](badge.svg)`; + + expect(copiedMarkdownFrom(render(markdown))).toBe(markdown); + }); + + it("escapes a literal backslash in authored image alt text", () => { + const markdown = String.raw`![folder\\name](badge.svg)`; + + expect(copiedMarkdownFrom(render(markdown))).toBe(markdown); + }); + + it("escapes a literal backslash before a quote in an authored image title", () => { + const html = render( + String.raw`logo`, + ); + + expect(copiedMarkdownFrom(html)).toBe( + String.raw`![logo](images/logo.svg "Path \\\"Title\\\"")`, + ); + }); + + it("uses a static bounded-width placeholder while a signed asset URL loads", () => { testState.assetState = "loading"; const html = render("![loading](.t3/workspace-image.svg)"); + const className = /]*aria-label="Loading image"[^>]*class="([^"]*)"/.exec(html)?.[1]; expect(html).toContain('aria-label="Loading image"'); expect(html).not.toContain("animate-pulse"); + expect(className?.split(" ")).toContain("w-64"); }); it("never passes a workspace source to a raw image when thread context is unavailable", () => { diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index cb814dace2e5..64ad1ce7d782 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -9,6 +9,7 @@ import { import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import type { Thread, ThreadShell } from "../types"; +import type { CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates"; import { MAX_HIDDEN_MOUNTED_PREVIEW_THREADS, MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, @@ -21,6 +22,7 @@ import { dismissBranchMismatchForSession, ENVIRONMENT_RECONNECT_WARNING_GRACE_MS, getStartedThreadModelChangeBlockReason, + isVideoPreviewRequestCurrent, hasEnvironmentReconnectWarningGraceElapsed, hasServerAcknowledgedLocalDispatch, isBranchMismatchDismissedForSession, @@ -33,16 +35,40 @@ import { resolveDraftHeroState, scheduleEnvironmentReconnectWarning, startNewThreadForProject, + codexArtifactTemplatePromptToAppend, shouldDockDraftHeroForSubmission, shouldReleaseTimelineAnchorForToolActivity, shouldShowBranchMismatchBanner, + shouldShowPlanFollowUpPrompt, shouldWriteThreadErrorToCurrentServerThread, } from "./ChatView.logic"; +describe("isVideoPreviewRequestCurrent", () => { + it("rejects changed threads and replaced previews", () => { + expect(isVideoPreviewRequestCurrent("thread-1", "thread-2", 1, 1)).toBe(false); + expect(isVideoPreviewRequestCurrent("thread-1", "thread-1", 1, 2)).toBe(false); + expect(isVideoPreviewRequestCurrent("thread-1", "thread-1", 2, 2)).toBe(true); + }); +}); + const environmentId = EnvironmentId.make("environment-local"); const projectId = ProjectId.make("project-1"); const threadId = ThreadId.make("thread-1"); const now = "2026-03-29T00:00:00.000Z"; +const helloWorldTemplate: CodexArtifactTemplate = { + artifactKind: "document", + displayName: "Hello World", + skillDirectory: "/Users/test/.codex/skills/artifact-template-hello-world", + skillName: "artifact-template-hello-world", +}; + +describe("artifact template composer insertion", () => { + it("does not insert an already-present prompt", () => { + const prompt = "Create a document using this $artifact-template-hello-world about…"; + + expect(codexArtifactTemplatePromptToAppend(prompt, helloWorldTemplate)).toBeNull(); + }); +}); describe("draft hero submission transition", () => { it("does not dock the composer before a background submission", () => { @@ -71,7 +97,7 @@ describe("draft hero submission transition", () => { expect( resolveDraftPromotionNavigationTarget({ serverThreadRef: { environmentId, threadId }, - serverThreadStarted: true, + serverThread: makeThread({ latestTurn: completedTurn }), backgroundSubmissionPending: true, }), ).toBeNull(); @@ -272,6 +298,66 @@ const readySession = { updatedAt: "2026-03-29T00:00:10.000Z", }; +describe("draft promotion during worktree setup", () => { + const serverThreadRef = { environmentId, threadId }; + + it.each([null, "idle", "starting", "ready"] as const)( + "keeps the draft mounted while the first turn waits with session %s", + (status) => { + const serverThread = makeThread({ + messages: [ + { + id: MessageId.make("submitted-message"), + role: "user", + text: "Start in a new worktree", + turnId: null, + createdAt: now, + updatedAt: now, + streaming: false, + }, + ], + session: status ? { ...readySession, status } : null, + }); + + expect( + resolveDraftPromotionNavigationTarget({ + serverThreadRef, + serverThread, + backgroundSubmissionPending: false, + }), + ).toBeNull(); + }, + ); + + it("promotes when the provider starts the first turn", () => { + const latestTurn = { ...completedTurn, state: "running" as const, completedAt: null }; + + expect( + resolveDraftPromotionNavigationTarget({ + serverThreadRef, + serverThread: makeThread({ + latestTurn, + session: { ...readySession, status: "running", activeTurnId: latestTurn.turnId }, + }), + backgroundSubmissionPending: false, + }), + ).toEqual(serverThreadRef); + }); + + it.each(["error", "stopped", "interrupted"] as const)( + "promotes a startup that ends as %s before a turn starts", + (status) => { + expect( + resolveDraftPromotionNavigationTarget({ + serverThreadRef, + serverThread: makeThread({ session: { ...readySession, status } }), + backgroundSubmissionPending: false, + }), + ).toEqual(serverThreadRef); + }, + ); +}); + describe("buildLoadingThreadFromShell", () => { it("preserves shell metadata and supplies empty detail collections", () => { const shell = { @@ -591,6 +677,31 @@ describe("shouldShowBranchMismatchBanner", () => { }); }); +describe("shouldShowPlanFollowUpPrompt", () => { + const base = { + pendingUserInputCount: 0, + interactionMode: "plan" as const, + latestTurnSettled: true, + hasActionableProposedPlan: true, + hasComposerAttachments: false, + }; + + it("shows plan actions for a settled actionable plan without attachments", () => { + expect(shouldShowPlanFollowUpPrompt(base)).toBe(true); + }); + + it("hides plan actions while the composer has staged attachments", () => { + expect(shouldShowPlanFollowUpPrompt({ ...base, hasComposerAttachments: true })).toBe(false); + }); + + it("preserves the existing plan follow-up gates", () => { + expect(shouldShowPlanFollowUpPrompt({ ...base, pendingUserInputCount: 1 })).toBe(false); + expect(shouldShowPlanFollowUpPrompt({ ...base, interactionMode: "default" })).toBe(false); + expect(shouldShowPlanFollowUpPrompt({ ...base, latestTurnSettled: false })).toBe(false); + expect(shouldShowPlanFollowUpPrompt({ ...base, hasActionableProposedPlan: false })).toBe(false); + }); +}); + describe("session branch mismatch dismissal", () => { it("tracks dismissed keys and treats other keys as active", () => { expect(isBranchMismatchDismissedForSession("t1:a:b")).toBe(false); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 83bea23b65e2..de349e3dbee7 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -1,9 +1,13 @@ import { + type AssetCreateUrlInput, + type AssetCreateUrlResult, + type ChatFileAttachment, type EnvironmentId, isProviderDriverKind, ProjectId, type MessageId, type ModelSelection, + type ProviderInteractionMode, type ProviderDriverKind, type ServerProvider, type ScopedProjectRef, @@ -11,7 +15,24 @@ import { type ThreadId, type TurnId, } from "@t3tools/contracts"; -import { type ChatMessage, type SessionPhase, type Thread, type ThreadShell } from "../types"; +import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; +import { + squashAtomCommandFailure, + type AtomCommandResult, +} from "@t3tools/client-runtime/state/runtime"; +import { videoMimeType } from "@t3tools/shared/video"; +import { + appendCodexArtifactTemplateUsePrompt, + codexArtifactTemplateUsePrompt, + type CodexArtifactTemplate, +} from "@t3tools/client-runtime/codex-artifact-templates"; +import { + type ChatMessage, + isImageAttachment, + type SessionPhase, + type Thread, + type ThreadShell, +} from "../types"; import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore"; import * as Schema from "effect/Schema"; import { appAtomRegistry } from "../rpc/atomRegistry"; @@ -32,6 +53,15 @@ export const ENVIRONMENT_RECONNECT_WARNING_GRACE_MS = 2_000; export const LastInvokedScriptByProjectSchema = Schema.Record(ProjectId, Schema.String); +export function codexArtifactTemplatePromptToAppend( + currentDraft: string, + template: CodexArtifactTemplate, +): string | null { + return appendCodexArtifactTemplateUsePrompt(currentDraft, template) === currentDraft + ? null + : codexArtifactTemplateUsePrompt(template); +} + export function shouldDockDraftHeroForSubmission(input: { isDraftHeroState: boolean; activeThreadKey: string | null; @@ -89,13 +119,19 @@ export function resolveDraftHeroState(input: { export function resolveDraftPromotionNavigationTarget(input: { serverThreadRef: ScopedThreadRef | null; - serverThreadStarted: boolean; + serverThread: Pick | null | undefined; backgroundSubmissionPending: boolean; }): ScopedThreadRef | null { if (input.backgroundSubmissionPending) { return null; } - return input.serverThreadStarted ? input.serverThreadRef : null; + const sessionStatus = input.serverThread?.session?.status; + const turnStarted = input.serverThread?.latestTurn?.startedAt != null; + const startupStopped = + sessionStatus === "error" || sessionStatus === "stopped" || sessionStatus === "interrupted"; + // Keep local preparation feedback mounted until the server can render the + // running turn or its startup error on the canonical thread route. + return turnStarted || startupStopped ? input.serverThreadRef : null; } export function scheduleEnvironmentReconnectWarning(showWarning: () => void): () => void { @@ -272,12 +308,49 @@ export function revokeBlobPreviewUrl(previewUrl: string | undefined): void { URL.revokeObjectURL(previewUrl); } +/** Signs an attachment URL without reading its bytes, so video playback can request byte ranges. */ +export async function resolveFileAttachmentUrl(input: { + attachment: ChatFileAttachment; + environmentId: EnvironmentId; + httpBaseUrl: string; + createAssetUrl: (input: { + environmentId: EnvironmentId; + input: AssetCreateUrlInput; + }) => Promise>; +}): Promise { + const { attachment } = input; + const result = await input.createAssetUrl({ + environmentId: input.environmentId, + input: { + resource: { + _tag: "attachment", + attachmentId: attachment.id, + fileName: attachment.name, + mimeType: videoMimeType(attachment) ?? attachment.mimeType, + }, + }, + }); + if (result._tag === "Failure") throw squashAtomCommandFailure(result); + const url = resolveAssetUrl(input.httpBaseUrl, result.value.relativeUrl); + if (url === null) throw new Error("The environment returned an invalid attachment URL."); + return url; +} + +export function isVideoPreviewRequestCurrent( + requestThreadKey: string, + currentThreadKey: string, + requestId: number, + currentRequestId: number, +): boolean { + return requestThreadKey === currentThreadKey && requestId === currentRequestId; +} + export function revokeUserMessagePreviewUrls(message: ChatMessage): void { if (message.role !== "user" || !message.attachments) { return; } for (const attachment of message.attachments) { - if (attachment.type !== "image") { + if (!isImageAttachment(attachment)) { continue; } revokeBlobPreviewUrl(attachment.previewUrl); @@ -290,7 +363,7 @@ export function collectUserMessageBlobPreviewUrls(message: ChatMessage): string[ } const previewUrls: string[] = []; for (const attachment of message.attachments) { - if (attachment.type !== "image") continue; + if (!isImageAttachment(attachment)) continue; if (!attachment.previewUrl || !attachment.previewUrl.startsWith("blob:")) continue; previewUrls.push(attachment.previewUrl); } @@ -439,6 +512,22 @@ export function shouldShowBranchMismatchBanner(input: { return input.composerHasContent || input.wasShownForCurrentMismatch; } +export function shouldShowPlanFollowUpPrompt(input: { + pendingUserInputCount: number; + interactionMode: ProviderInteractionMode; + latestTurnSettled: boolean; + hasActionableProposedPlan: boolean; + hasComposerAttachments: boolean; +}): boolean { + return ( + input.pendingUserInputCount === 0 && + input.interactionMode === "plan" && + input.latestTurnSettled && + input.hasActionableProposedPlan && + !input.hasComposerAttachments + ); +} + // Session-scoped (module-level so it survives ChatView remounts, e.g. route // changes). Durable cross-device dismissal is planned as a server-side ack. const sessionDismissedBranchMismatchKeys = new Set(); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index cb1cf698535a..dc0d2ed122f5 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1,5 +1,6 @@ import { type ApprovalRequestId, + type ChatFileAttachment, DEFAULT_MODEL, defaultInstanceIdForDriver, type EnvironmentId, @@ -17,6 +18,7 @@ import { type TurnId, type KeybindingCommand, OrchestrationThreadActivity, + PROVIDER_SEND_TURN_MAX_ATTACHMENTS, ProviderInteractionMode, ProviderDriverKind, RuntimeMode, @@ -27,12 +29,8 @@ import { type EnvironmentConnectionPresentation, } from "@t3tools/client-runtime/connection"; import { wasBootstrapThreadDeleted } from "@t3tools/client-runtime/errors"; -import { - changeRequestAutoSettles, - effectiveSettled, - effectiveSnoozed, - threadWokeAt, -} from "@t3tools/client-runtime/state/thread-settled"; +import { type CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates"; +import { effectiveSnoozed, threadWokeAt } from "@t3tools/client-runtime/state/thread-settled"; import { codexFeedbackMessage, parseCodexFeedbackCommand, @@ -50,9 +48,9 @@ import { createModelSelection, resolvePromptInjectedEffort, } from "@t3tools/shared/model"; -import { CHAT_LIST_ANCHOR_OFFSET } from "@t3tools/shared/chatList"; import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; import { truncate } from "@t3tools/shared/String"; +import { resolveThreadReferenceCopyTarget } from "@t3tools/shared/threadReference"; import { getTerminalLabel, nextTerminalId, @@ -82,6 +80,7 @@ import { type AtomCommandResult, } from "@t3tools/client-runtime/state/runtime"; import * as Cause from "effect/Cause"; +import * as Schema from "effect/Schema"; import { AsyncResult } from "effect/unstable/reactivity"; import { isElectron } from "../env"; import { readLocalApi } from "../localApi"; @@ -98,14 +97,17 @@ import { deriveTimelineEntries, deriveActiveWorkStartedAt, deriveActivePlanState, - deriveTurnPlans, findLatestProposedPlan, deriveWorkLogEntries, hasActionableProposedPlan, isLatestTurnSettled, } from "../session-logic"; import { type LegendListRef } from "@legendapp/list/react"; -import { getAnchoredTurnMetrics, type TimelineScrollMode } from "./chat/timelineScrollAnchoring"; +import { + CHAT_TIMELINE_ANCHOR_OFFSET, + getAnchoredTurnMetrics, + type TimelineScrollMode, +} from "./chat/timelineScrollAnchoring"; import { buildPendingUserInputAnswers, derivePendingUserInputProgress, @@ -114,6 +116,10 @@ import { type PendingUserInputDraftAnswer, } from "../pendingUserInput"; import { useUiStateStore } from "../uiStateStore"; +import { + latestWorkspaceMutationId, + useWorkspaceMutationRefresh, +} from "../hooks/useWorkspaceMutationRefresh"; import { buildPlanImplementationThreadTitle, buildPlanImplementationPrompt, @@ -125,6 +131,8 @@ import { DEFAULT_THREAD_TERMINAL_ID, MAX_TERMINALS_PER_GROUP, type ChatMessage, + isImageAttachment, + videoMimeType, type SessionPhase, type Thread, type TurnDiffSummary, @@ -179,6 +187,7 @@ import { CheckCircle2Icon, ChevronDownIcon, GitBranchIcon, + Minimize2Icon, PaperclipIcon, WifiOffIcon, } from "lucide-react"; @@ -196,7 +205,11 @@ import { newDraftId, newMessageId, newThreadId } from "~/lib/utils"; import { useBrowserHistoryStore } from "~/browserHistoryStore"; import { registerFaviconProjectForThread } from "~/browserFaviconStore"; import { getProviderModelCapabilities, resolveSelectableProvider } from "../providerModels"; -import { NO_PROVIDER_MODEL_SELECTION } from "../providerInstances"; +import { + applyProviderInstanceSettings, + deriveProviderInstanceEntries, + NO_PROVIDER_MODEL_SELECTION, +} from "../providerInstances"; import { useClientSettings, useClientSettingsHydrated, @@ -204,6 +217,7 @@ import { } from "../hooks/useSettings"; import { useNowMinute } from "../hooks/useNowMinute"; import { useNewThreadHandler } from "../hooks/useHandleNewThread"; +import { useThreadActions } from "../hooks/useThreadActions"; import { resolveAppModelSelectionForInstance } from "../modelSelection"; import { confirmTerminalClose, isTerminalCloseConfirmPending } from "../lib/terminalCloseConfirm"; import { getTerminalFocusOwner } from "../lib/terminalFocus"; @@ -222,6 +236,8 @@ import { buildDraftThreadRouteParams, buildThreadRouteParams } from "../threadRo import { beginBackgroundDraftSubmissionByRef, clearBackgroundDraftSubmissionByRef, + composerDraftHasUserContent, + type ComposerFileAttachment, type ComposerImageAttachment, type DraftThreadEnvMode, finalizePromotedDraftThreadByRef, @@ -246,8 +262,10 @@ import { environmentCatalog } from "../connection/catalog"; import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../state/terminalSessions"; import { projectEnvironment } from "../state/projects"; +import { linkedPullRequestDetailAtom } from "../state/pullRequests"; import { useEnvironmentQuery } from "../state/query"; import { + environmentServerConfigsAtom, primaryServerAvailableEditorsAtom, primaryServerKeybindingsAtom, primaryServerSettingsAtom, @@ -300,10 +318,18 @@ import { } from "./chat/ThreadErrorBanner"; import { resolveDisplayedThreadPr, + threadPullRequestRefreshSource, threadChangeRequestSnapshotsAtom, + useLinkedThreadPullRequest, } from "./ThreadStatusIndicators"; -import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/ComposerBannerStack"; -import { ThreadSyncStatusPill } from "./chat/ThreadSyncStatusPill"; +import type { ComposerBannerStackItem } from "./chat/ComposerBannerStack"; +import { ComposerSurface } from "./chat/ComposerSurface"; +import { + hasAvailableClaudeCompactionProvider, + hasDismissedResumeCompaction, + shouldOfferResumeCompaction, +} from "./chat/ContextWindowMeter.logic"; +import { deriveLatestContextWindowSnapshot, formatContextWindowTokens } from "../lib/contextWindow"; import { DRAFT_HERO_TRANSITION_ANIMATION_ID, DRAFT_HERO_TRANSITION_DURATION_MS, @@ -330,6 +356,7 @@ import { shouldDockDraftHeroForSubmission, shouldReleaseTimelineAnchorForToolActivity, shouldShowBranchMismatchBanner, + shouldShowPlanFollowUpPrompt, getStartedThreadModelChangeBlockReason, LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, LastInvokedScriptByProjectSchema, @@ -338,6 +365,8 @@ import { cloneComposerImageForRetry, deriveLockedProvider, readFileAsDataUrl, + resolveFileAttachmentUrl, + isVideoPreviewRequestCurrent, reconcileMountedTerminalThreadIds, resolveBackgroundDraftWorkspaceOptions, resolveDraftHeroState, @@ -347,6 +376,7 @@ import { revokeUserMessagePreviewUrls, shouldWriteThreadErrorToCurrentServerThread, startNewThreadForProject, + codexArtifactTemplatePromptToAppend, waitForStartedServerThread, } from "./ChatView.logic"; import type { ThreadSyncPhase } from "../threadSync"; @@ -355,13 +385,19 @@ import { useComposerHandleContext } from "../composerHandleContext"; import { awaitAttachmentUploads, getUploadedAttachments, - releaseAttachmentUploads, + releaseDraftAttachments, startAttachmentUpload, } from "../lib/attachmentUploadQueue"; import { sanitizeThreadErrorMessage } from "~/rpc/transportError"; import { RightPanelSheet } from "./RightPanelSheet"; import { previewEnvironment } from "../state/preview"; +import { clampFileAttachmentUploadBytes } from "@t3tools/client-runtime/state/attachments"; +import { appAtomRegistry } from "../rpc/atomRegistry"; +import { fileAttachmentCapabilityBlockReason } from "./chat/composerAttachmentFiles"; +import { assetEnvironment } from "../state/assets"; +import { readPreparedConnection } from "../state/session"; import { useAtomCommand } from "../state/use-atom-command"; +import { useAtomQueryRunner } from "../state/use-atom-query-runner"; import { Button } from "./ui/button"; import { AlertDialog, @@ -373,10 +409,16 @@ import { AlertDialogTitle, } from "./ui/alert-dialog"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; -import { ServerUpdateAction, ServerUpdateProgress } from "./ServerUpdateAction"; +import { ServerUpdateAction } from "./ServerUpdateAction"; +import { + ComposerServerUpdateIcon, + ComposerServerUpdateStatus, +} from "./chat/ComposerServerUpdateStatus"; import { buildVersionMismatchDismissalKey, + dismissServerUpdateFailure, dismissVersionMismatch, + isServerUpdateFailureDismissed, isVersionMismatchDismissed, resolveServerConfigVersionMismatch, resolveServerSelfUpdateCapability, @@ -384,8 +426,8 @@ import { } from "../versionSkew"; import { useAssetUrls } from "../assets/assetUrls"; -const IMAGE_ONLY_BOOTSTRAP_PROMPT = - "[User attached one or more images without additional text. Respond using the conversation context and the attached image(s).]"; +const ATTACHMENT_ONLY_BOOTSTRAP_PROMPT = + "[User attached one or more files without additional text. Respond using the conversation context and the attached files.]"; const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = []; const EMPTY_PROVIDERS: ServerProvider[] = []; const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = []; @@ -489,7 +531,11 @@ const TYPE_TO_FOCUS_INTERACTIVE_SELECTOR = [ '[role="tab"]', ].join(","); const TYPE_TO_FOCUS_FLOATING_LAYER_SELECTOR = [ - '[data-slot="dialog"]', + '[data-slot="alert-dialog-popup"]:is([data-open],[data-ending-style])', + '[data-slot="command-dialog-popup"]:is([data-open],[data-ending-style])', + '[data-slot="dialog-popup"]:is([data-open],[data-ending-style])', + '[data-slot="sheet-popup"]:is([data-open],[data-ending-style])', + '[data-slot="sidebar"][data-mobile="true"]:is([data-open],[data-ending-style])', '[data-slot="menu-popup"]', '[data-slot="select-popup"]', '[data-slot="popover-popup"]', @@ -1257,6 +1303,7 @@ function ChatViewContent(props: ChatViewProps) { const threadSyncPhase = routeKind === "server" ? (props.threadSyncPhase ?? null) : null; const threadDetailLoading = threadSyncPhase === "loading"; const handleNewThread = useNewThreadHandler(); + const { settleThread, pinThread, confirmAndUnpinThread } = useThreadActions(); const routeThreadRef = useMemo( () => scopeThreadRef(environmentId, threadId), [environmentId, threadId], @@ -1282,6 +1329,10 @@ function ChatViewContent(props: ChatViewProps) { reportFailure: false, }); const startThreadTurn = useAtomCommand(threadEnvironment.startTurn, { reportFailure: false }); + const createAttachmentAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { + reportFailure: false, + refresh: true, + }); const uploadThreadFeedback = useAtomCommand(threadEnvironment.uploadFeedback, { reportFailure: false, }); @@ -1364,8 +1415,16 @@ function ChatViewContent(props: ChatViewProps) { const composerActiveProvider = useComposerDraftStore( (store) => store.getComposerDraft(composerDraftTarget)?.activeProvider ?? null, ); + const composerHasUnsentContent = useComposerDraftStore((store) => + composerDraftHasUserContent(store.getComposerDraft(composerDraftTarget)), + ); + const composerHasAttachments = useComposerDraftStore((store) => { + const draft = store.getComposerDraft(composerDraftTarget); + return (draft?.images.length ?? 0) > 0 || (draft?.files.length ?? 0) > 0; + }); const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt); const addComposerDraftImages = useComposerDraftStore((store) => store.addImages); + const addComposerDraftFiles = useComposerDraftStore((store) => store.addFiles); const setComposerDraftTerminalContexts = useComposerDraftStore( (store) => store.setTerminalContexts, ); @@ -1392,13 +1451,26 @@ function ChatViewContent(props: ChatViewProps) { ); const promptRef = useRef(""); const composerImagesRef = useRef([]); + const composerFilesRef = useRef([]); const composerTerminalContextsRef = useRef([]); const composerElementContextsRef = useRef([]); const localComposerRef = useRef(null); const composerRef = useComposerHandleContext() ?? localComposerRef; const [isWorkspaceFileDragActive, setIsWorkspaceFileDragActive] = useState(false); + const routeThreadKeyRef = useRef(routeThreadKey); + routeThreadKeyRef.current = routeThreadKey; + const videoPreviewRequestIdRef = useRef(0); + const cancelVideoPreviewRequest = useCallback(() => { + videoPreviewRequestIdRef.current += 1; + }, []); + const [openingVideoAttachmentId, setOpeningVideoAttachmentId] = useState(null); const [showScrollToBottom, setShowScrollToBottom] = useState(false); const [expandedImage, setExpandedImage] = useState(null); + useEffect(() => { + const item = expandedImage?.images[expandedImage.index]; + if (item?.type !== "video" || !item.src.startsWith("blob:")) return; + return () => revokeBlobPreviewUrl(item.src); + }, [expandedImage]); const [optimisticUserMessages, setOptimisticUserMessages] = useState([]); const [feedbackSubmissionsByThreadKey, setFeedbackSubmissionsByThreadKey] = useState< Record> @@ -1467,6 +1539,7 @@ function ChatViewContent(props: ChatViewProps) { const legendListRef = useRef(null); const [composerOverlayElement, setComposerOverlayElement] = useState(null); const [composerOverlayHeight, setComposerOverlayHeight] = useState(0); + const [scrollToEndClearance, setScrollToEndClearance] = useState(0); const isAtEndRef = useRef(true); const attachmentPreviewHandoffByMessageIdRef = useRef>({}); const attachmentPreviewPromotionInFlightByMessageIdRef = useRef>({}); @@ -1474,25 +1547,6 @@ function ChatViewContent(props: ChatViewProps) { const feedbackUploadsInFlightRef = useRef(new Set()); const terminalUiOpenByThreadRef = useRef>({}); - useLayoutEffect(() => { - if (!composerOverlayElement) return; - - const updateHeight = () => { - const nextHeight = Math.ceil(composerOverlayElement.getBoundingClientRect().height); - if (nextHeight <= 0) return; - setComposerOverlayHeight((currentHeight) => - currentHeight === nextHeight ? currentHeight : nextHeight, - ); - }; - - updateHeight(); - if (typeof ResizeObserver === "undefined") return; - - const observer = new ResizeObserver(updateHeight); - observer.observe(composerOverlayElement); - return () => observer.disconnect(); - }, [composerOverlayElement]); - const terminalUiState = useTerminalUiStateStore((state) => selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef), ); @@ -1691,7 +1745,7 @@ function ChatViewContent(props: ChatViewProps) { // the tab is found again whether or not that surface was opened with an environment on it. const activePullRequestSurfaceId = activeRightPanelSurface?.kind === "pull-request" ? activeRightPanelSurface.id : undefined; - const handlePullRequestTabStatusChange = useCallback( + const updatePullRequestTabStatusFromPanel = useCallback( (status: PullRequestTabStatus) => { const id = activePullRequestSurfaceId; if (id === undefined) return; @@ -1699,6 +1753,8 @@ function ChatViewContent(props: ChatViewProps) { }, [activePullRequestSurfaceId], ); + const refreshVcsStatus = useAtomCommand(vcsEnvironment.refreshStatus, { reportFailure: false }); + const sidebarPrRefreshKeyRef = useRef(null); const activeFileSurface = activeRightPanelSurface?.kind === "file" ? activeRightPanelSurface : null; const activePreviewState = useThreadPreviewState(activeThreadRef); @@ -2104,6 +2160,12 @@ function ChatViewContent(props: ChatViewProps) { const attachmentUploadsCapabilityKnown = attachmentEnvironmentConfig !== null; const supportsAttachmentUploads = attachmentEnvironmentConfig?.environment.capabilities.attachmentUploads === true; + const advertisedFileAttachmentBytes = + attachmentEnvironmentConfig?.environment.capabilities.fileAttachments?.maxUploadBytes ?? null; + const maxFileAttachmentBytes = + advertisedFileAttachmentBytes === null + ? null + : clampFileAttachmentUploadBytes(advertisedFileAttachmentBytes); const versionMismatch = resolveServerConfigVersionMismatch(serverConfig); const versionMismatchDismissKey = versionMismatch && activeThread @@ -2127,6 +2189,12 @@ function ChatViewContent(props: ChatViewProps) { const serverUpdateState = useAtomValue( serverEnvironment.updateStateAtom(serverUpdateEnvironmentId), ); + const [dismissedServerUpdateState, setDismissedServerUpdateState] = useState< + typeof serverUpdateState | null + >(null); + const serverUpdateFailureDismissed = + serverUpdateState === dismissedServerUpdateState || + isServerUpdateFailureDismissed(serverUpdateState); const systemComposerBannerItems = useMemo(() => { const items: ComposerBannerStackItem[] = []; const updateRunning = serverUpdateState.status === "running"; @@ -2154,8 +2222,8 @@ function ChatViewContent(props: ChatViewProps) { items.push({ id: `environment-unavailable:${activeEnvironmentUnavailableState.environmentId}`, variant: "default", - // Live connection status: calm styling, but it must front the stack. - urgent: true, + // Prioritize live connection progress among the notices. + priority: "urgent", icon: (
    +
    ); diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index dbba327489ac..6964702726ef 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -10,7 +10,6 @@ import { isAtomCommandInterrupted, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; -import type { ChangeRequestSettleSource } from "@t3tools/client-runtime/state/thread-settled"; import { ChevronDownIcon } from "lucide-react"; import { memo, @@ -53,8 +52,6 @@ interface ChatHeaderProps { activeThreadTitle: string; /** Drafts have no server thread yet, so the title carries no action menu. */ isServerThread: boolean; - /** PR feeding the settled classification, resolved by ChatView. */ - changeRequest: ChangeRequestSettleSource | null; activeProjectName: string | undefined; activeProjectCwd: string | null; activeProjectFaviconPath: string | null; @@ -123,7 +120,6 @@ export const ChatHeader = memo(function ChatHeader({ draftId, activeThreadTitle, isServerThread, - changeRequest, activeProjectName, activeProjectCwd, activeProjectFaviconPath, @@ -201,7 +197,6 @@ export const ChatHeader = memo(function ChatHeader({ const { openMenu, closeMenu } = useThreadActionMenu({ threadRef: isServerThread ? activeThreadRef : null, projectCwd: activeProjectCwd, - changeRequest, onStartRename: startRename, }); const titleButtonRef = useRef(null); @@ -288,13 +283,16 @@ export const ChatHeader = memo(function ChatHeader({ className="@container/header-actions flex min-w-0 flex-1 items-center gap-2 sm:gap-3" onContextMenu={handleHeaderContextMenu} > - + {/* The project always leads the header: knowing which project a thread lives in is priority zero, and the thread title alone doesn't answer it. */} {activeProjectName ? ( <> - + } > @@ -320,7 +318,7 @@ export const ChatHeader = memo(function ChatHeader({ ) : null} - + {renamingTitle !== null ? ( + + + + + + {threadSyncLabel(phase)} + + + + ); +} diff --git a/apps/web/src/components/chat/ComposerBanner.tsx b/apps/web/src/components/chat/ComposerBanner.tsx new file mode 100644 index 000000000000..d222972b4151 --- /dev/null +++ b/apps/web/src/components/chat/ComposerBanner.tsx @@ -0,0 +1,351 @@ +import { mergeProps } from "@base-ui/react/merge-props"; +import { useRender } from "@base-ui/react/use-render"; +import { ChevronDownIcon, XIcon } from "lucide-react"; +import type { ComponentProps } from "react"; + +import { cn } from "~/lib/utils"; +import { Button, buttonVariants } from "../ui/button"; +import { ScrollArea } from "../ui/scroll-area"; + +export type ComposerBannerVariant = "default" | "error" | "info" | "success" | "warning"; + +const surfaceColors = cn( + "[--chat-composer-attached-surface:var(--chat-composer-glass-surface,var(--card))]", + "dark:[--chat-composer-attached-surface:var(--chat-composer-glass-surface,color-mix(in_srgb,var(--background)_96%,var(--color-white)))]", + "[html[data-theme-id]_&]:[--chat-composer-attached-surface:var(--app-theme-surface-raised)]", +); + +const neutralOutline = cn( + "[--chat-composer-attached-outline:var(--chat-composer-outline,color-mix(in_srgb,var(--contrast-foreground)_8%,transparent))]", + "dark:[--chat-composer-attached-outline:var(--chat-composer-outline,color-mix(in_srgb,var(--color-white)_5%,transparent))]", + "[html[data-theme-id]_&]:[--chat-composer-attached-outline:var(--chat-composer-outline,var(--app-theme-toolbar-border))]", + "dark:[html[data-theme-id]:not([data-theme-id=t3-chat])_&]:[--chat-composer-attached-outline:var(--chat-composer-outline,color-mix(in_srgb,var(--app-theme-input)_30%,var(--background)))]", + "dark:[html[data-theme-id=t3-chat]_&]:[--chat-composer-attached-outline:#241e28]", +); + +const variantColors: Record = { + default: neutralOutline, + error: + "[--chat-composer-attached-outline:color-mix(in_srgb,var(--error)_32%,transparent)] [--chat-composer-attached-tint:color-mix(in_srgb,var(--error)_8%,transparent)]", + info: neutralOutline, + success: neutralOutline, + warning: + "[--chat-composer-attached-outline:color-mix(in_srgb,var(--warning)_28%,transparent)] [--chat-composer-attached-tint:color-mix(in_srgb,var(--warning)_8%,transparent)]", +}; + +/** Shared glass and attachment seam, also used by the command menu without banner row padding. */ +function Surface({ + placement = "attached", + variant = "default", + className, + ...props +}: ComponentProps<"div"> & { + placement?: "attached" | "floating"; + variant?: ComposerBannerVariant; +}) { + return ( +
    + ); +} + +// A peeking notice uses the first hidden notice's severity, never the attached row's. +const peekBorder: Record = { + default: "border-(--chat-composer-attached-outline)", + error: "border-destructive/24", + info: "border-(--chat-composer-attached-outline)", + success: "border-(--chat-composer-attached-outline)", + warning: "border-warning/24", +}; + +function Peek({ + className, + variant = "default", + ...props +}: ComponentProps<"button"> & { variant?: ComposerBannerVariant }) { + return ( + + ); +} + +export const ComposerBanner = { + Surface, + Peek, + Attachment, + Dock, + Column, + Root, + Row, + Icon, + Content, + Separator, + Actions, + Children, + Scroll, + Count, + Body, + Dot, + ToggleIcon, + Dismiss, +}; diff --git a/apps/web/src/components/chat/ComposerBannerStack.test.tsx b/apps/web/src/components/chat/ComposerBannerStack.test.tsx deleted file mode 100644 index adbabba25c7d..000000000000 --- a/apps/web/src/components/chat/ComposerBannerStack.test.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import { renderToStaticMarkup } from "react-dom/server"; -import { describe, expect, it } from "vite-plus/test"; - -import { ComposerBannerStack, type ComposerBannerStackItem } from "./ComposerBannerStack"; - -const banner = ( - id: string, - variant: ComposerBannerStackItem["variant"] = "warning", -): ComposerBannerStackItem => ({ - id, - variant, - icon: , - title: `${id} warning`, -}); - -describe("ComposerBannerStack", () => { - it("keeps expanded banners in layout flow so surrounding content moves out of their way", () => { - const markup = renderToStaticMarkup( - , - ); - - const expandedItems = markup.match( - /
    /, - ); - - expect(expandedItems?.[1]).toContain("grid-rows-[0fr]"); - expect(expandedItems?.[1]).toContain("group-hover/banner-stack:grid-rows-[1fr]"); - expect(expandedItems?.[1]).toContain("z-20"); - expect(expandedItems?.[1]).not.toContain("absolute"); - expect(markup.indexOf("front warning")).toBeLessThan(markup.indexOf("stacked warning")); - expect(markup).toContain("invisible pointer-events-none"); - expect(markup).toContain("group-focus-within/banner-stack:visible"); - }); - - it("colors the collapsed stack cap by the hidden banner's variant, not a fixed warning", () => { - const neutralBehind = renderToStaticMarkup( - , - ); - expect(neutralBehind).toContain("chat-composer-banner-stack-cap"); - expect(neutralBehind).toContain("border-[var(--chat-composer-attached-outline)]"); - expect(neutralBehind).not.toContain("border-border"); - expect(neutralBehind).not.toContain("border-warning/24"); - - const warningBehind = renderToStaticMarkup( - , - ); - expect(warningBehind).toContain("border-warning/24"); - }); - - it("does not render an expandable region for a single banner", () => { - const markup = renderToStaticMarkup(); - - expect(markup).not.toContain("data-composer-banner-stack-expanded-items"); - expect(markup).toContain("chat-composer-drawer-surface"); - expect(markup).toContain("chat-composer-drawer-attached"); - expect(markup).not.toContain("before:mask-none"); - expect(markup).toContain("text-xs"); - expect(markup).toContain('data-composer-banner-drawer="true"'); - expect(markup).toContain('data-variant="warning"'); - expect(markup).toContain("transform:none"); - expect(markup).not.toContain("will-change:transform"); - }); - it("applies item-specific surface and action layout classes", () => { - const markup = renderToStaticMarkup( - Repair, - }, - ]} - />, - ); - - expect(markup).toContain("branch-surface"); - expect(markup).toContain("branch-actions"); - }); -}); diff --git a/apps/web/src/components/chat/ComposerBannerStack.tsx b/apps/web/src/components/chat/ComposerBannerStack.tsx index d8b8761447cb..6f335a82b378 100644 --- a/apps/web/src/components/chat/ComposerBannerStack.tsx +++ b/apps/web/src/components/chat/ComposerBannerStack.tsx @@ -1,60 +1,54 @@ -import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react"; -import { XIcon } from "lucide-react"; +import { useEffect, useId, useLayoutEffect, useRef, useState, type ReactNode } from "react"; import { cn } from "~/lib/utils"; -import { Alert, AlertAction, AlertDescription, AlertTitle } from "../ui/alert"; -import { Button } from "../ui/button"; +import { ComposerBanner, type ComposerBannerVariant } from "./ComposerBanner"; +// Match the duration-220 exit transition before removing a dismissed notice. const DISMISS_TRANSITION_MS = 220; -const frontExitStyle = { - opacity: 0, - transform: "translate3d(0, 4rem, 0)", -} satisfies CSSProperties; -const stackedExitStyle = { - opacity: 0, - transform: "translate3d(0, 7rem, 0)", -} satisfies CSSProperties; -const restingStyle = { - opacity: 1, - transform: "none", -} satisfies CSSProperties; -const exitTransitionStyle = { - transition: `transform ${DISMISS_TRANSITION_MS}ms ease-in, opacity ${DISMISS_TRANSITION_MS}ms ease-in`, -} satisfies CSSProperties; - -// The collapsed cap peeking above the front banner is the only hint that more -// banners are stacked behind it, so its border must match the severity of the -// first hidden banner — a neutral banner must not masquerade as a warning. -const stackCapBorderClass: Record = { - default: "border-[var(--chat-composer-attached-outline)]", - error: "border-destructive/24", - info: "border-info/24", - success: "border-success/24", - warning: "border-warning/24", -}; export interface ComposerBannerStackItem { readonly id: string; - readonly variant: "default" | "error" | "info" | "success" | "warning"; - // Ordering hint for stack assemblers: front this banner even though its - // variant is calm (e.g. live update progress). The stack itself ignores it. - readonly urgent?: boolean; + readonly variant: ComposerBannerVariant; + readonly priority?: "urgent" | "activity" | "notice"; readonly icon: ReactNode; readonly title: ReactNode; readonly description?: ReactNode; + readonly children?: ReactNode; readonly actions?: ReactNode; readonly className?: string; - readonly actionClassName?: string; readonly dismissLabel?: string; readonly onDismiss?: () => void; } +export type ComposerBannerStackContent = Pick< + ComposerBannerStackItem, + "id" | "variant" | "priority" | "className" +> & { readonly content: ReactNode }; + +type ComposerBannerStackEntry = ComposerBannerStackItem | ComposerBannerStackContent; + +function bannerPriority(item: ComposerBannerStackEntry) { + if (item.priority === "activity") { + return 0; + } + if (item.priority === "urgent" || item.variant === "error" || item.variant === "warning") { + return 1; + } + return 2; +} + interface ComposerBannerStackProps { readonly className?: string; - readonly items: ReadonlyArray; + readonly items: ReadonlyArray; } export function ComposerBannerStack({ className, items }: ComposerBannerStackProps) { + const [stackExpanded, setStackExpanded] = useState(false); + const noticesRef = useRef(null); + const peekRef = useRef(null); + const expandedItemsRef = useRef(null); + const pendingFocusRef = useRef<"peek" | "notice" | null>(null); + const expandedItemsId = useId(); const [requestedExitingItemId, setExitingItemId] = useState(null); const dismissTimeoutRef = useRef | null>(null); const exitingItemId = @@ -70,21 +64,40 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro }; }, []); + useEffect(() => { + if (items.length < 2) setStackExpanded(false); + }, [items.length]); + + useLayoutEffect(() => { + if (stackExpanded && pendingFocusRef.current === "notice") { + pendingFocusRef.current = null; + const firstControl = expandedItemsRef.current?.querySelector( + 'button:not(:disabled), a[href], input:not(:disabled), [tabindex="0"]', + ); + (firstControl ?? expandedItemsRef.current)?.focus({ preventScroll: true }); + } else if (!stackExpanded && pendingFocusRef.current === "peek") { + pendingFocusRef.current = null; + peekRef.current?.focus({ preventScroll: true }); + } + }, [stackExpanded]); + if (items.length === 0) { return null; } - const frontItem = items[0]; + // Activity stays attached. Urgency and severity only order the notices behind it. + const orderedItems = items.toSorted((a, b) => bannerPriority(a) - bannerPriority(b)); + const frontItem = orderedItems[0]; if (!frontItem) { return null; } - const stackedItems = items.slice(1); + const stackedItems = orderedItems.slice(1); const hasStack = stackedItems.length > 0; const showCollapsedStackCap = hasStack && exitingItemId !== frontItem.id; const firstStackedItem = stackedItems[0]; - const requestDismiss = (item: ComposerBannerStackItem) => { - if (!item.onDismiss || exitingItemId) { + const requestDismiss = (item: ComposerBannerStackEntry) => { + if (!("onDismiss" in item) || !item.onDismiss || exitingItemId) { return; } setExitingItemId(item.id); @@ -98,37 +111,28 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro }; return ( -
    -
    - {showCollapsedStackCap && firstStackedItem ? ( - {props.showProvider && (
    diff --git a/apps/web/src/components/chat/ModelPickerContent.test.ts b/apps/web/src/components/chat/ModelPickerContent.test.ts new file mode 100644 index 000000000000..cbee3b6b16e5 --- /dev/null +++ b/apps/web/src/components/chat/ModelPickerContent.test.ts @@ -0,0 +1,67 @@ +import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { deriveProviderInstanceEntries } from "../../providerInstances"; +import { shouldIncludeModelPickerOption } from "./ModelPickerContent"; + +function entry(status: ServerProvider["status"]) { + return deriveProviderInstanceEntries([ + { + instanceId: ProviderInstanceId.make("opencode_work"), + driver: ProviderDriverKind.make("opencode"), + enabled: true, + installed: true, + version: null, + status, + auth: { status: "authenticated" }, + checkedAt: "2026-08-28T00:00:00.000Z", + models: [], + slashCommands: [], + skills: [], + }, + ])[0]!; +} + +describe("shouldIncludeModelPickerOption", () => { + it.each(["error", "warning"] as const)( + "keeps only the active synthetic OpenCode row when the provider status is %s", + (status) => { + const providerEntry = entry(status); + const activeInstanceId = ProviderInstanceId.make("opencode_work"); + const activeModel = "openrouter/kimi-k3"; + + expect( + shouldIncludeModelPickerOption({ + entry: providerEntry, + option: { + slug: activeModel, + name: activeModel, + isUnavailable: true, + }, + activeInstanceId, + activeModel, + }), + ).toBe(true); + expect( + shouldIncludeModelPickerOption({ + entry: providerEntry, + option: { slug: "stale/model", name: "Stale model" }, + activeInstanceId, + activeModel, + }), + ).toBe(false); + expect( + shouldIncludeModelPickerOption({ + entry: providerEntry, + option: { + slug: "other/missing", + name: "Other missing", + isUnavailable: true, + }, + activeInstanceId, + activeModel, + }), + ).toBe(false); + }, + ); +}); diff --git a/apps/web/src/components/chat/ModelPickerContent.tsx b/apps/web/src/components/chat/ModelPickerContent.tsx index 8729b1bf8f00..8d94fde1c0e8 100644 --- a/apps/web/src/components/chat/ModelPickerContent.tsx +++ b/apps/web/src/components/chat/ModelPickerContent.tsx @@ -15,7 +15,6 @@ import { parseModelPickerLegacySectionKey, parseModelPickerModelKey, } from "./modelPickerKeys"; -import { isModelPickerNewModel } from "./modelPickerModelHighlights"; import { buildModelPickerSearchText, scoreModelPickerSearch } from "./modelPickerSearch"; import { Combobox, @@ -47,14 +46,32 @@ type ModelPickerItem = { name: string; shortName?: string; subProvider?: string; + badge?: "new"; instanceId: ProviderInstanceId; driverKind: ProviderDriverKind; instanceDisplayName: string; instanceAccentColor?: string | undefined; continuationGroupKey?: string | undefined; isLegacy?: boolean | undefined; + isUnavailable?: boolean | undefined; }; +export function shouldIncludeModelPickerOption(input: { + readonly entry: ProviderInstanceEntry; + readonly option: ModelEsque; + readonly activeInstanceId: ProviderInstanceId; + readonly activeModel: string; +}): boolean { + if (isProviderInstancePickerReady(input.entry)) return true; + return ( + input.entry.enabled && + input.entry.driverKind === "opencode" && + input.entry.instanceId === input.activeInstanceId && + input.option.slug === input.activeModel && + input.option.isUnavailable === true + ); +} + const EMPTY_MODEL_JUMP_LABELS = new Map(); function ModelListSeparator() { @@ -107,9 +124,23 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { const modelListRef = useRef(null); const highlightedModelKeyRef = useRef(null); const favorites = useClientSettings((s) => s.favorites ?? []); + const activeEntry = props.instanceEntries.find( + (entry) => entry.instanceId === props.activeInstanceId, + ); + const activeInstanceHasSelectableUnavailableModel = + activeEntry !== undefined && + (modelOptionsByInstance.get(props.activeInstanceId) ?? []).some((option) => + shouldIncludeModelPickerOption({ + entry: activeEntry, + option, + activeInstanceId: props.activeInstanceId, + activeModel: props.model, + }), + ) && + !isProviderInstancePickerReady(activeEntry); const [selectedInstanceId, setSelectedInstanceId] = useState( () => { - if (props.lockedProvider !== null) { + if (props.lockedProvider !== null || activeInstanceHasSelectableUnavailableModel) { // When locked, prime the sidebar to the currently-active instance // so jumping into the picker keeps the focused instance visible. return props.activeInstanceId; @@ -189,15 +220,12 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { [props.lockedContinuationGroupKey, props.lockedProvider], ); - const readyInstanceSet = useMemo(() => { - const ready = new Set(); - for (const entry of instanceEntries) { - if (isProviderInstancePickerReady(entry)) { - ready.add(entry.instanceId); - } + const selectableUnavailableInstanceIds = useMemo(() => { + if (!activeInstanceHasSelectableUnavailableModel) { + return undefined; } - return ready; - }, [instanceEntries]); + return new Set([props.activeInstanceId]); + }, [activeInstanceHasSelectableUnavailableModel, props.activeInstanceId]); // Flatten models into a searchable array. One pass over the // instance-keyed map; each model carries its instance id + driver kind @@ -212,16 +240,25 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { // its models — stale options shouldn't appear in the picker. continue; } - if (!readyInstanceSet.has(instanceId)) { - continue; - } for (const model of models) { + if ( + !shouldIncludeModelPickerOption({ + entry, + option: model, + activeInstanceId: props.activeInstanceId, + activeModel: props.model, + }) + ) { + continue; + } out.push({ slug: model.slug, name: model.name, ...(model.shortName ? { shortName: model.shortName } : {}), ...(model.subProvider ? { subProvider: model.subProvider } : {}), + ...(model.badge ? { badge: model.badge } : {}), ...(model.isLegacy ? { isLegacy: true } : {}), + ...(model.isUnavailable ? { isUnavailable: true } : {}), instanceId, driverKind: entry.driverKind, instanceDisplayName: entry.displayName, @@ -233,7 +270,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { } } return out; - }, [modelOptionsByInstance, entryByInstanceId, readyInstanceSet]); + }, [modelOptionsByInstance, entryByInstanceId, props.activeInstanceId, props.model]); const isLocked = props.lockedProvider !== null; const isSearching = searchQuery.trim().length > 0; @@ -609,6 +646,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { onSelectInstance={handleSelectInstance} instanceEntries={sidebarInstanceEntries} showFavorites + {...(selectableUnavailableInstanceIds ? { selectableUnavailableInstanceIds } : {})} {...(lockedDisabledInstanceIds ? { disabledInstanceIds: lockedDisabledInstanceIds, @@ -767,7 +805,8 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { showProvider preferShortName={!isLocked} useTriggerLabel={false} - showNewBadge={isModelPickerNewModel(model.driverKind, model.slug)} + showNewBadge={model.badge === "new"} + unavailable={model.isUnavailable === true} jumpLabel={modelJumpLabelByKey.get(modelKey) ?? null} disabledReason={disabledReason} onToggleFavorite={() => toggleFavorite(model.instanceId, model.slug)} diff --git a/apps/web/src/components/chat/ModelPickerSidebar.tsx b/apps/web/src/components/chat/ModelPickerSidebar.tsx index 98db6a9c18cd..dd53270268ba 100644 --- a/apps/web/src/components/chat/ModelPickerSidebar.tsx +++ b/apps/web/src/components/chat/ModelPickerSidebar.tsx @@ -54,6 +54,8 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { showFavorites?: boolean; /** Instance ids shown in the rail but unavailable for the current picker context. */ disabledInstanceIds?: ReadonlySet; + /** Non-ready instances whose selected unavailable model remains reachable. */ + selectableUnavailableInstanceIds?: ReadonlySet; getDisabledInstanceTooltip?: (entry: ProviderInstanceEntry) => string; /** * Instance id values that should render the "new" sparkle badge. Callers @@ -135,7 +137,10 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { {props.instanceEntries.map((entry) => { const isUnavailable = !isProviderInstancePickerReady(entry); const isContextDisabled = props.disabledInstanceIds?.has(entry.instanceId) ?? false; - const isDisabled = isUnavailable || isContextDisabled; + const unavailableSelectionIsReachable = + props.selectableUnavailableInstanceIds?.has(entry.instanceId) ?? false; + const isDisabled = + (isUnavailable && !unavailableSelectionIsReachable) || isContextDisabled; const isSelected = props.selectedInstanceId === entry.instanceId; const isHovered = hoveredInstanceId === entry.instanceId; const showNewBadge = props.newBadgeInstanceIds?.has(entry.instanceId) ?? false; @@ -168,7 +173,7 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { disabled={isDisabled} type="button" aria-label={ - isDisabled + isUnavailable || isContextDisabled ? tooltip : showNewBadge ? `${entry.displayName}, new` diff --git a/apps/web/src/components/chat/OpenInPicker.tsx b/apps/web/src/components/chat/OpenInPicker.tsx index afe35e185203..b9bf831c14d9 100644 --- a/apps/web/src/components/chat/OpenInPicker.tsx +++ b/apps/web/src/components/chat/OpenInPicker.tsx @@ -7,6 +7,7 @@ import { import { memo, useCallback, useEffect, useMemo } from "react"; import { isOpenFavoriteEditorShortcut, shortcutLabelForCommand } from "../../keybindings"; import { usePreferredEditor } from "../../editorPreferences"; +import { editorLabelForPlatform } from "../../editorLabels"; import { openRemoteEditorUrl, useRemoteCapableEditors, @@ -43,7 +44,7 @@ import { RustRoverIcon, WebStormIcon, } from "../JetBrainsIcons"; -import { cn, isMacPlatform, isWindowsPlatform } from "~/lib/utils"; +import { cn } from "~/lib/utils"; import { shellEnvironment } from "~/state/shell"; import { useAtomCommand } from "~/state/use-atom-command"; @@ -55,140 +56,117 @@ type OpenInOption = { }; const resolveOptions = (platform: string, availableEditors: ReadonlyArray) => { - const baseOptions: ReadonlyArray = [ + const baseOptions: ReadonlyArray> = [ { - label: "Cursor", Icon: CursorIcon, value: "cursor", kind: "brand", }, { - label: "Trae", Icon: TraeIcon, value: "trae", kind: "brand", }, { - label: "Kiro", Icon: KiroIcon, value: "kiro", kind: "brand", }, { - label: "VS Code", Icon: VisualStudioCode, value: "vscode", kind: "brand", }, { - label: "VS Code Insiders", Icon: VisualStudioCodeInsiders, value: "vscode-insiders", kind: "brand", }, { - label: "VSCodium", Icon: VSCodium, value: "vscodium", kind: "brand", }, { - label: "Zed", Icon: Zed, value: "zed", kind: "brand", }, { - label: "Antigravity", Icon: AntigravityIcon, value: "antigravity", kind: "brand", }, { - label: "IntelliJ IDEA", Icon: IntelliJIdeaIcon, value: "idea", kind: "brand", }, { - label: "Aqua", Icon: AquaIcon, value: "aqua", kind: "brand", }, { - label: "CLion", Icon: CLionIcon, value: "clion", kind: "brand", }, { - label: "DataGrip", Icon: DataGripIcon, value: "datagrip", kind: "brand", }, { - label: "DataSpell", Icon: DataSpellIcon, value: "dataspell", kind: "brand", }, { - label: "GoLand", Icon: GoLandIcon, value: "goland", kind: "brand", }, { - label: "PhpStorm", Icon: PhpStormIcon, value: "phpstorm", kind: "brand", }, { - label: "PyCharm", Icon: PyCharmIcon, value: "pycharm", kind: "brand", }, { - label: "Rider", Icon: RiderIcon, value: "rider", kind: "brand", }, { - label: "RubyMine", Icon: RubyMineIcon, value: "rubymine", kind: "brand", }, { - label: "RustRover", Icon: RustRoverIcon, value: "rustrover", kind: "brand", }, { - label: "WebStorm", Icon: WebStormIcon, value: "webstorm", kind: "brand", }, { - label: isMacPlatform(platform) - ? "Finder" - : isWindowsPlatform(platform) - ? "Explorer" - : "Files", Icon: FolderClosedIcon, value: "file-manager", kind: "generic", }, ]; const availableEditorSet = new Set(availableEditors); - return baseOptions.filter((option) => availableEditorSet.has(option.value)); + return baseOptions + .filter((option) => availableEditorSet.has(option.value)) + .map((option) => ({ ...option, label: editorLabelForPlatform(option.value, platform) })); }; function getOpenInIconClass(kind: OpenInOption["kind"]) { diff --git a/apps/web/src/components/chat/ProviderModelPicker.test.tsx b/apps/web/src/components/chat/ProviderModelPicker.test.tsx new file mode 100644 index 000000000000..5d5bf94ce631 --- /dev/null +++ b/apps/web/src/components/chat/ProviderModelPicker.test.tsx @@ -0,0 +1,102 @@ +import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vite-plus/test"; + +import { deriveProviderInstanceEntries } from "../../providerInstances"; +import { ProviderModelPicker } from "./ProviderModelPicker"; +import type { ModelEsque } from "./providerIconUtils"; + +function providerEntry(instanceId: string, driver: string) { + const provider: ServerProvider = { + instanceId: ProviderInstanceId.make(instanceId), + driver: ProviderDriverKind.make(driver), + enabled: true, + installed: true, + version: null, + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-08-28T00:00:00.000Z", + models: [], + slashCommands: [], + skills: [], + }; + return deriveProviderInstanceEntries([provider])[0]!; +} + +function renderPicker(input: { + instanceId: string; + driver: string; + model: string; + options: ReadonlyArray; + includeEntry?: boolean; +}) { + const instanceId = ProviderInstanceId.make(input.instanceId); + const entry = providerEntry(input.instanceId, input.driver); + return renderToStaticMarkup( + {}} + />, + ); +} + +describe("ProviderModelPicker", () => { + it("shows a missing model slug for a custom OpenCode instance", () => { + const markup = renderPicker({ + instanceId: "team_runtime", + driver: "opencode", + model: "openrouter/missing-model", + options: [{ slug: "openrouter/fallback", name: "Fallback model" }], + }); + + expect(markup).toContain("openrouter/missing-model"); + expect(markup).not.toContain("Fallback model"); + }); + + it.each(["codex", "claudeAgent", "cursor", "grok"])( + "uses the first option label for a missing %s model", + (driver) => { + const markup = renderPicker({ + instanceId: `${driver}_work`, + driver, + model: "missing-model", + options: [{ slug: "fallback-model", name: "Fallback model" }], + }); + + expect(markup).toContain("Fallback model"); + expect(markup).not.toContain(">missing-model<"); + }, + ); + + it("prefers a matching model for OpenCode", () => { + const markup = renderPicker({ + instanceId: "custom_runtime", + driver: "opencode", + model: "openrouter/selected", + options: [ + { slug: "openrouter/fallback", name: "Fallback model" }, + { slug: "openrouter/selected", name: "Selected model" }, + ], + }); + + expect(markup).toContain("Selected model"); + expect(markup).not.toContain("Fallback model"); + }); + + it("uses the first option when the active instance entry is missing", () => { + const markup = renderPicker({ + instanceId: "missing_instance", + driver: "opencode", + model: "missing-model", + options: [{ slug: "fallback-model", name: "Fallback model" }], + includeEntry: false, + }); + + expect(markup).toContain("Fallback model"); + expect(markup).not.toContain(">missing-model<"); + }); +}); diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx index 5566160bcf3e..db55edcf8835 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.tsx @@ -5,6 +5,7 @@ import { } from "@t3tools/contracts"; import { memo, useEffect, useMemo, useState } from "react"; import type { VariantProps } from "class-variance-authority"; +import { Badge } from "../ui/badge"; import { buttonVariants } from "../ui/button"; import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; @@ -58,15 +59,15 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { const activeInstanceId = props.activeInstanceId; const selectedInstanceOptions = props.modelOptionsByInstance.get(activeInstanceId) ?? []; - // If the current slug belongs to a different instance (for example after - // a provider switch or disable), prefer the active instance's first - // option so the trigger icon and label stay in sync instead of showing - // a stale foreign slug. + // OpenCode can keep a model through a transient catalog refresh. Other + // providers keep the active instance's first option as their normal fallback. const selectedModel = selectedInstanceOptions.find((option) => option.slug === props.model) ?? - selectedInstanceOptions[0]; + (activeEntry?.driverKind === "opencode" ? undefined : selectedInstanceOptions[0]); const triggerTitle = selectedModel ? getTriggerDisplayModelName(selectedModel) : props.model; - const triggerLabel = selectedModel ? getTriggerDisplayModelLabel(selectedModel) : props.model; + const triggerLabel = selectedModel + ? `${getTriggerDisplayModelLabel(selectedModel)}${selectedModel.isUnavailable ? " (Unavailable)" : ""}` + : props.model; const showInstanceBadge = activeEntry !== null && shouldShowInstanceBadge(activeEntry, props.instanceEntries); @@ -179,6 +180,11 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { {triggerLabel} + {selectedModel?.isUnavailable ? ( + + Unavailable + + ) : null}
    {entriesQuery.error && entriesQuery.data === null ? (
    {entriesQuery.error}
    diff --git a/apps/web/src/components/files/FileMarkdownPreview.tsx b/apps/web/src/components/files/FileMarkdownPreview.tsx new file mode 100644 index 000000000000..e36ada48acb8 --- /dev/null +++ b/apps/web/src/components/files/FileMarkdownPreview.tsx @@ -0,0 +1,34 @@ +import type { ScopedThreadRef } from "@t3tools/contracts"; + +import ChatMarkdown from "~/components/ChatMarkdown"; +import { resolvePathLinkTarget } from "~/terminal-links"; + +export function FileMarkdownPreview(props: { + readonly cwd: string; + readonly relativePath: string; + readonly text: string; + readonly threadRef: ScopedThreadRef; + readonly onTaskListChange?: + | ((input: { readonly markerOffset: number; readonly checked: boolean }) => void) + | undefined; +}) { + const lastSeparator = Math.max( + props.relativePath.lastIndexOf("/"), + props.relativePath.lastIndexOf("\\"), + ); + const imageBaseDir = + lastSeparator >= 0 + ? resolvePathLinkTarget(props.relativePath.slice(0, lastSeparator), props.cwd) + : props.cwd; + + return ( + + ); +} diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index a8c364763c28..1272fa7d4974 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -4,7 +4,10 @@ import type { ResolvedKeybindingsConfig, ScopedThreadRef, } from "@t3tools/contracts"; -import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview"; +import { + isWorkspaceImagePreviewPath, + isWorkspaceVideoPreviewPath, +} from "@t3tools/shared/filePreview"; import { VirtualizedFile, type SelectedLineRange } from "@pierre/diffs"; import { Editor } from "@pierre/diffs/editor"; import { EditProvider, File, type FileOptions, Virtualizer } from "@pierre/diffs/react"; @@ -12,18 +15,21 @@ import { isAtomCommandInterrupted, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; +import { mediaFileReference } from "@t3tools/client-runtime/media-reference"; import { ChevronRight, Code2, Eye, FolderTree, Globe2, LoaderCircle } from "lucide-react"; import * as Schema from "effect/Schema"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { isBrowserPreviewFile, openFileInPreview } from "~/browser/openFileInPreview"; -import { useAssetUrlState } from "~/assets/assetUrls"; -import ChatMarkdown from "~/components/ChatMarkdown"; +import { useAssetUrlRefresh, useAssetUrlState } from "~/assets/assetUrls"; import { OpenInPicker } from "~/components/chat/OpenInPicker"; +import { MediaVideoPlayer } from "~/components/media/MediaVideoPlayer"; +import { MediaActions, type MediaActionSource } from "~/components/media/MediaActions"; import { useRemoteOpenState } from "~/remoteOpen"; import { useClientSettings } from "~/hooks/useSettings"; import { useTheme } from "~/hooks/useTheme"; import { getLocalStorageItem, setLocalStorageItem, useLocalStorage } from "~/hooks/useLocalStorage"; +import { useWorkspaceMutationRefresh } from "~/hooks/useWorkspaceMutationRefresh"; import { DIFF_SURFACE_THEME_UNSAFE_CSS, resolveDiffThemeName } from "~/lib/diffRendering"; import { cn } from "~/lib/utils"; import { isPreviewSupportedInRuntime } from "~/previewStateStore"; @@ -42,6 +48,7 @@ import { useAtomCommand } from "~/state/use-atom-command"; import { useAtomQueryRunner } from "~/state/use-atom-query-runner"; import FileBrowserPanel from "./FileBrowserPanel"; +import { FileMarkdownPreview } from "./FileMarkdownPreview"; import { type FileCommentAnnotationEntry, type FileCommentAnnotationGroup, @@ -78,6 +85,8 @@ interface FilePreviewPanelProps { revealRequestId: number; onOpenFile: (relativePath: string) => void; onPendingChange: (relativePath: string, pending: boolean) => void; + selectedFilePending: boolean; + workspaceMutationId: string | null; } const FILE_EXPLORER_STORAGE_KEY = "t3code.fileExplorerOpen"; @@ -132,31 +141,53 @@ function WorkspaceImagePreview(props: { readonly environmentId: EnvironmentId; readonly threadRef: ScopedThreadRef; readonly absolutePath: string; + readonly workspaceRoot: string; readonly alt: string; + readonly workspaceMutationId: string | null; }) { - const assetUrl = useAssetUrlState(props.environmentId, { - _tag: "workspace-file", - threadId: props.threadRef.threadId, - path: props.absolutePath, - }); + const resource = useMemo( + () => ({ + _tag: "workspace-file" as const, + threadId: props.threadRef.threadId, + path: props.absolutePath, + }), + [props.threadRef.threadId, props.absolutePath], + ); + const assetUrl = useAssetUrlState(props.environmentId, resource); const [failedUrl, setFailedUrl] = useState(null); + const revisionSuffix = + props.workspaceMutationId === null + ? "" + : `${assetUrl._tag === "Success" && assetUrl.url.includes("?") ? "&" : "?"}workspace-revision=${encodeURIComponent(props.workspaceMutationId)}`; + const imageUrl = assetUrl._tag === "Success" ? `${assetUrl.url}${revisionSuffix}` : null; + const actionsSource: MediaActionSource = { + kind: "image", + name: props.alt, + src: imageUrl, + reference: mediaFileReference(props.absolutePath, props.workspaceRoot), + asset: { environmentId: props.environmentId, resource }, + }; - if (assetUrl._tag === "Failure" || (assetUrl._tag === "Success" && failedUrl === assetUrl.url)) { + if (assetUrl._tag === "Failure" || (imageUrl !== null && failedUrl === imageUrl)) { return ( -
    - Unable to load workspace image. -
    + +
    + Unable to load workspace image. +
    +
    ); } - return assetUrl._tag === "Success" ? ( + return assetUrl._tag === "Success" && imageUrl !== null ? (
    - {props.alt} setFailedUrl(assetUrl.url)} - /> + + {props.alt} setFailedUrl(imageUrl)} + /> +
    ) : (
    @@ -165,6 +196,60 @@ function WorkspaceImagePreview(props: { ); } +function WorkspaceVideoPreview(props: { + readonly environmentId: EnvironmentId; + readonly threadRef: ScopedThreadRef; + readonly absolutePath: string; + readonly workspaceRoot: string; + readonly name: string; + readonly workspaceMutationId: string | null; +}) { + const resource = useMemo( + () => ({ + _tag: "media-file" as const, + threadId: props.threadRef.threadId, + path: props.absolutePath, + }), + [props.threadRef.threadId, props.absolutePath], + ); + const assetUrl = useAssetUrlState(props.environmentId, resource); + const refreshAssetUrl = useAssetUrlRefresh(props.environmentId, resource); + useWorkspaceMutationRefresh({ + mutationId: props.workspaceMutationId, + resourceKey: JSON.stringify([props.environmentId, resource]), + refresh: () => { + // Failed refreshes flow through assetUrl and can be retried from the player. + void refreshAssetUrl().catch(() => undefined); + }, + }); + const revisionSuffix = + props.workspaceMutationId === null + ? "" + : `${assetUrl._tag === "Success" && assetUrl.url.includes("?") ? "&" : "?"}workspace-revision=${encodeURIComponent(props.workspaceMutationId)}`; + const latestUrl = assetUrl._tag === "Success" ? `${assetUrl.url}${revisionSuffix}` : null; + + return ( +
    + +
    + ); +} + function clampFileLine(contents: string, requestedLine: number): number { let lineCount = 1; for (let index = 0; index < contents.length; index += 1) { @@ -727,11 +812,11 @@ function RenderedMarkdownSurface({ return ( - { const currentContents = getOptimisticProjectFileQueryData(environmentId, cwd, relativePath)?.contents ?? @@ -768,6 +853,8 @@ export default function FilePreviewPanel({ revealRequestId, onOpenFile, onPendingChange, + selectedFilePending, + workspaceMutationId, }: FilePreviewPanelProps) { const { resolvedTheme } = useTheme(); const wordWrap = useClientSettings((settings) => settings.wordWrap); @@ -780,8 +867,10 @@ export default function FilePreviewPanel({ const openPreview = useAtomCommand(previewEnvironment.open, { reportFailure: false, }); - const isImage = relativePath !== null && isWorkspaceImagePreviewPath(relativePath); - const file = useProjectFileQuery(environmentId, cwd, relativePath, !isImage); + const isVideo = relativePath !== null && isWorkspaceVideoPreviewPath(relativePath); + const isImage = relativePath !== null && !isVideo && isWorkspaceImagePreviewPath(relativePath); + const isMedia = isImage || isVideo; + const file = useProjectFileQuery(environmentId, cwd, relativePath, !isMedia); const [explorerOpen, setExplorerOpen] = useState(initialExplorerOpen); // Reading markdown rendered is a preference, not a property of one file. Keeping // it on the panel meant a thread switch dropped it and forced source back. @@ -805,13 +894,22 @@ export default function FilePreviewPanel({ (revealLine === null || (handledReveal?.path === relativePath && handledReveal.requestId === revealRequestId)); const canOpenInBrowser = - relativePath !== null && isPreviewSupportedInRuntime() && isBrowserPreviewFile(relativePath); + relativePath !== null && + !isVideo && + isPreviewSupportedInRuntime() && + isBrowserPreviewFile(relativePath); const absolutePath = relativePath ? resolvePathLinkTarget(relativePath, cwd) : null; const breadcrumbs = useMemo( () => (relativePath ? fileBreadcrumbs(projectName, relativePath) : []), [projectName, relativePath], ); const onFilePostRender = useFileLineReveal(relativePath, revealLine, revealRequestId); + useWorkspaceMutationRefresh({ + enabled: relativePath !== null && !isMedia && !selectedFilePending, + mutationId: workspaceMutationId, + refresh: file.refresh, + resourceKey: `file:${environmentId}:${cwd}:${relativePath ?? ""}`, + }); useEffect(() => { const currentCrumb = breadcrumbRef.current?.querySelector( @@ -982,7 +1080,7 @@ export default function FilePreviewPanel({
    ) : null} - {relativePath && file.data?.truncated ? ( + {relativePath && !isMedia && file.data?.truncated ? (
    Preview limited to the first 1 MB of a {file.data.byteLength.toLocaleString()} byte file.
    @@ -994,13 +1092,25 @@ export default function FilePreviewPanel({ relativePath ? "flex" : "hidden", )} > - {relativePath && isImage && absolutePath ? ( + {relativePath && isVideo && absolutePath ? ( + + ) : relativePath && isImage && absolutePath ? ( ) : relativePath && file.error && file.data === null ? (
    @@ -1080,7 +1190,8 @@ export default function FilePreviewPanel({ selectedPath={relativePath} selectedPathRevealId={revealRequestId} onOpenFile={onOpenFile} - {...(relativePath && !isImage ? { onRefreshSelectedFile: file.refresh } : {})} + workspaceMutationId={workspaceMutationId} + {...(relativePath && !isMedia ? { onRefreshSelectedFile: file.refresh } : {})} /> ) : null} diff --git a/apps/web/src/components/files/fileTreeExpansion.test.ts b/apps/web/src/components/files/fileTreeExpansion.test.ts new file mode 100644 index 000000000000..1fba6957728f --- /dev/null +++ b/apps/web/src/components/files/fileTreeExpansion.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it, vi } from "@effect/vitest"; + +import { areAllDirectoriesExpanded, setAllDirectoriesExpanded } from "./fileTreeExpansion"; + +type FakeDirectoryItem = { + isDirectory: () => true; + isExpanded: () => boolean; + expand: () => void; + collapse: () => void; +}; + +function makeModel(expanded: Record) { + const items = new Map(); + return { + getItem: (path: string) => { + const existing = items.get(path); + if (existing !== undefined) return existing; + const item: FakeDirectoryItem = { + isDirectory: () => true, + isExpanded: () => expanded[path] ?? false, + expand: () => { + expanded[path] = true; + }, + collapse: () => { + expanded[path] = false; + }, + }; + items.set(path, item); + return item; + }, + }; +} + +describe("file tree expansion", () => { + it("requires at least one directory and detects whether all are expanded", () => { + const model = makeModel({ "src/": true, "test/": true }); + expect(areAllDirectoriesExpanded(model, [])).toBe(false); + expect(areAllDirectoriesExpanded(model, ["src/", "test/"])).toBe(true); + expect( + areAllDirectoriesExpanded(makeModel({ "src/": true, "test/": false }), ["src/", "test/"]), + ).toBe(false); + }); + + it("expands and collapses every directory", () => { + const expanded = { "src/": true, "test/": false }; + const model = makeModel(expanded); + setAllDirectoriesExpanded(model, ["src/", "test/"], true); + expect(expanded).toEqual({ "src/": true, "test/": true }); + setAllDirectoriesExpanded(model, ["src/", "test/"], false); + expect(expanded).toEqual({ "src/": false, "test/": false }); + }); + + it("skips directories already at the requested state", () => { + const model = makeModel({ "src/": true }); + const item = model.getItem("src/"); + const collapse = vi.spyOn(item, "collapse"); + setAllDirectoriesExpanded(model, ["src/"], true); + expect(collapse).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/components/files/fileTreeExpansion.ts b/apps/web/src/components/files/fileTreeExpansion.ts new file mode 100644 index 000000000000..221e62b64c96 --- /dev/null +++ b/apps/web/src/components/files/fileTreeExpansion.ts @@ -0,0 +1,55 @@ +export interface FileTreeExpansionModel { + getItem(path: string): unknown; +} + +type DirectoryHandle = { + isDirectory(): boolean; + isExpanded(): boolean; + expand(): void; + collapse(): void; +}; + +function asDirectoryHandle(item: unknown): DirectoryHandle | null { + if ( + typeof item !== "object" || + item === null || + !("isDirectory" in item) || + typeof item.isDirectory !== "function" || + !item.isDirectory() || + !("isExpanded" in item) || + typeof item.isExpanded !== "function" || + !("expand" in item) || + typeof item.expand !== "function" || + !("collapse" in item) || + typeof item.collapse !== "function" + ) { + return null; + } + return item as DirectoryHandle; +} + +export function areAllDirectoriesExpanded( + model: FileTreeExpansionModel, + directoryPaths: readonly string[], +): boolean { + return ( + directoryPaths.length > 0 && + directoryPaths.every((path) => { + const item = asDirectoryHandle(model.getItem(path)); + return item !== null && item.isExpanded(); + }) + ); +} + +export function setAllDirectoriesExpanded( + model: FileTreeExpansionModel, + directoryPaths: readonly string[], + expanded: boolean, +): void { + for (const path of directoryPaths) { + const item = asDirectoryHandle(model.getItem(path)); + if (item === null || item.isExpanded() === expanded) continue; + if (expanded) item.expand(); + else item.collapse(); + } +} diff --git a/apps/web/src/components/files/projectFilesQueryState.test.tsx b/apps/web/src/components/files/projectFilesQueryState.test.tsx new file mode 100644 index 000000000000..fdb412159ada --- /dev/null +++ b/apps/web/src/components/files/projectFilesQueryState.test.tsx @@ -0,0 +1,193 @@ +import { EnvironmentId, type ProjectReadFileResult } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import { Atom, AtomRegistry } from "effect/unstable/reactivity"; +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const projectMocks = vi.hoisted(() => ({ + listEntries: vi.fn(), + optimisticFile: vi.fn(), + readFile: vi.fn(), +})); + +const atomHooks = vi.hoisted(() => ({ + registry: null as { + get(atom: object): unknown; + refresh(atom: object): void; + } | null, +})); + +const reactHooks = vi.hoisted(() => { + let cursor = 0; + let refs: Array<{ current: unknown }> = []; + const nextIndex = () => cursor++; + + return { + beginRender() { + cursor = 0; + }, + reset() { + cursor = 0; + refs = []; + }, + useCallback(callback: A): A { + nextIndex(); + return callback; + }, + useEffect(effect: () => void): void { + nextIndex(); + effect(); + }, + useRef(initialValue: A): { current: A } { + const index = nextIndex(); + refs[index] ??= { current: initialValue }; + return refs[index] as { current: A }; + }, + }; +}); + +vi.mock("@effect/atom-react", () => ({ + useAtomRefresh: (atom: object) => () => { + atomHooks.registry?.refresh(atom); + }, + useAtomValue: (atom: object) => atomHooks.registry?.get(atom), +})); + +vi.mock("react", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useCallback: reactHooks.useCallback, + useEffect: reactHooks.useEffect, + useRef: reactHooks.useRef, + }; +}); + +vi.mock("~/state/projects", () => ({ + projectEnvironment: projectMocks, +})); + +vi.mock("~/state/queries", () => ({ + useProjectPathSearch: vi.fn(), +})); + +import { useWorkspaceMutationRefresh } from "~/hooks/useWorkspaceMutationRefresh"; +import { useProjectFileQuery } from "./projectFilesQueryState"; + +const environmentId = EnvironmentId.make("environment-1"); + +function deferred() { + let resolve!: (value: A) => void; + const promise = new Promise((complete) => { + resolve = complete; + }); + return { promise, resolve }; +} + +function file(contents: string): ProjectReadFileResult { + return { + relativePath: "src/preview.ts", + contents, + byteLength: contents.length, + truncated: false, + }; +} + +async function flushEffects(): Promise { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); +} + +describe("project file query refresh", () => { + beforeEach(() => { + projectMocks.listEntries.mockReset(); + projectMocks.optimisticFile.mockReset(); + projectMocks.readFile.mockReset(); + reactHooks.reset(); + }); + + it("replaces an in-flight initial read when a workspace mutation arrives", async () => { + const requests: Array>> = []; + const readAtom = Atom.make( + Effect.promise(() => { + const request = deferred(); + requests.push(request); + return request.promise; + }), + ).pipe(Atom.swr({ staleTime: 30_000, revalidateOnMount: true })); + const registry = AtomRegistry.make(); + const unmount = registry.mount(readAtom); + projectMocks.readFile.mockReturnValue(readAtom); + projectMocks.optimisticFile.mockReturnValue(Atom.make(null)); + atomHooks.registry = registry; + let renderedContents: string | null = null; + + const render = (mutationId: string | null) => { + reactHooks.beginRender(); + const query = useProjectFileQuery(environmentId, "/repo", "src/preview.ts"); + renderedContents = query.data?.contents ?? null; + useWorkspaceMutationRefresh({ + mutationId, + refresh: query.refresh, + resourceKey: "file:environment-1:/repo:src/preview.ts", + }); + }; + + try { + render(null); + await flushEffects(); + expect(requests).toHaveLength(1); + + render("mutation-1"); + await flushEffects(); + expect(requests).toHaveLength(2); + + requests[1]!.resolve(file("fresh")); + await flushEffects(); + render("mutation-1"); + expect(renderedContents).toBe("fresh"); + + requests[0]!.resolve(file("stale")); + await flushEffects(); + render("mutation-1"); + expect(renderedContents).toBe("fresh"); + } finally { + unmount(); + registry.dispose(); + atomHooks.registry = null; + } + }); + + it("does not issue a file read for a disabled image preview", async () => { + const requests: Array>> = []; + const readAtom = Atom.make( + Effect.promise(() => { + const request = deferred(); + requests.push(request); + return request.promise; + }), + ); + const registry = AtomRegistry.make(); + projectMocks.readFile.mockReturnValue(readAtom); + projectMocks.optimisticFile.mockReturnValue(Atom.make(null)); + atomHooks.registry = registry; + + try { + reactHooks.beginRender(); + const query = useProjectFileQuery(environmentId, "/repo", "preview.png", false); + useWorkspaceMutationRefresh({ + enabled: false, + mutationId: "mutation-1", + refresh: query.refresh, + resourceKey: "file:environment-1:/repo:preview.png", + }); + await flushEffects(); + + expect(projectMocks.readFile).not.toHaveBeenCalled(); + expect(requests).toHaveLength(0); + } finally { + registry.dispose(); + atomHooks.registry = null; + } + }); +}); diff --git a/apps/web/src/components/files/projectFilesQueryState.ts b/apps/web/src/components/files/projectFilesQueryState.ts index 08203ff6b87a..d02ec99605ba 100644 --- a/apps/web/src/components/files/projectFilesQueryState.ts +++ b/apps/web/src/components/files/projectFilesQueryState.ts @@ -4,6 +4,10 @@ import type { ProjectListEntriesResult, ProjectReadFileResult, } from "@t3tools/contracts"; +import { + isWorkspaceImagePreviewPath, + isWorkspaceVideoPreviewPath, +} from "@t3tools/shared/filePreview"; import * as Cause from "effect/Cause"; import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; @@ -178,9 +182,13 @@ export function useProjectFileQuery( relativePath: string | null, enabled = true, ): ProjectQueryState { - const atom = enabled - ? getProjectFileQueryAtom(environmentId, cwd, relativePath) - : EMPTY_PROJECT_FILE_QUERY_ATOM; + const isMedia = + relativePath !== null && + (isWorkspaceImagePreviewPath(relativePath) || isWorkspaceVideoPreviewPath(relativePath)); + const atom = + enabled && !isMedia + ? getProjectFileQueryAtom(environmentId, cwd, relativePath) + : EMPTY_PROJECT_FILE_QUERY_ATOM; const result = useAtomValue(atom); const refreshAtom = useAtomRefresh(atom); const refresh = useCallback(() => refreshAtom(), [refreshAtom]); diff --git a/apps/web/src/components/media/MediaActions.tsx b/apps/web/src/components/media/MediaActions.tsx new file mode 100644 index 000000000000..a67cadbca5a1 --- /dev/null +++ b/apps/web/src/components/media/MediaActions.tsx @@ -0,0 +1,189 @@ +import { + mediaReferenceFileName, + type MediaReference, +} from "@t3tools/client-runtime/media-reference"; +import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; +import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; +import type { AssetResource, ContextMenuItem, EnvironmentId } from "@t3tools/contracts"; +import { useCallback, useRef, useState, type ReactElement } from "react"; + +import { writeTextToClipboard } from "../../hooks/useCopyToClipboard"; +import { readLocalApi } from "../../localApi"; +import { assetEnvironment } from "../../state/assets"; +import { readPreparedConnection } from "../../state/session"; +import { useAtomQueryRunner } from "../../state/use-atom-query-runner"; +import { stackedThreadToast, toastManager } from "../ui/toast"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { downloadMedia, readMediaPng } from "./mediaContent"; + +export interface MediaActionSource { + readonly kind: "image" | "video"; + readonly name: string; + readonly src: string | null; + readonly reference?: MediaReference; + readonly asset?: { readonly environmentId: EnvironmentId; readonly resource: AssetResource }; + readonly onOpenFile?: () => void; +} + +function mediaFileName(source: MediaActionSource): string { + return ( + (source.reference && mediaReferenceFileName(source.reference)) || source.name || source.kind + ); +} + +/** Explicit byte operations get fresh capabilities without replacing a player's active source. */ +export function useMediaActions(source: MediaActionSource) { + const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { + reportFailure: false, + refresh: true, + }); + const actionUrl = useCallback(async () => { + if (!source.asset) { + if (!source.src) throw new Error("This media is unavailable. Try reopening the preview."); + return source.src; + } + const { environmentId, resource } = source.asset; + const connection = readPreparedConnection(environmentId); + if (!connection) throw new Error("Reconnect to this environment and try again."); + const result = await createAssetUrl({ environmentId, input: { resource } }); + if (result._tag === "Failure") throw squashAtomCommandFailure(result); + const url = resolveAssetUrl(connection.httpBaseUrl, result.value.relativeUrl); + if (!url) throw new Error("The environment returned an invalid media URL."); + return url; + }, [source, createAssetUrl]); + const save = useCallback(async () => { + await downloadMedia(await actionUrl(), mediaFileName(source)); + }, [actionUrl, source]); + const copyImage = useCallback(async () => { + if (!navigator.clipboard?.write || typeof ClipboardItem === "undefined") { + throw new Error( + "Image copying is unavailable. Use a secure browser connection or save the image.", + ); + } + // Start the clipboard write in the user gesture; fetching/decoding may finish later. + await navigator.clipboard.write([ + new ClipboardItem({ "image/png": actionUrl().then(readMediaPng) }), + ]); + }, [actionUrl]); + return { save, copyImage }; +} + +type MediaAction = "copy-full" | "copy-relative" | "copy-url" | "save" | "copy-image" | "open-file"; + +/** Adds source-aware actions and a tooltip to the existing media element without a layout wrapper. */ +export function MediaActions({ + source, + children, +}: { + source: MediaActionSource; + children: ReactElement; +}) { + const { save, copyImage } = useMediaActions(source); + const [tooltipOpen, setTooltipOpen] = useState(false); + const menuOpen = useRef(false); + const reference = source.reference; + const hasActions = + source.kind === "image" || reference !== undefined || source.onOpenFile !== undefined; + const tooltip = reference?.kind === "file" ? reference.path : (reference?.url ?? source.name); + + const showMenu = async (position: { x: number; y: number }) => { + const api = readLocalApi(); + if (!api || menuOpen.current) return; + menuOpen.current = true; + setTooltipOpen(false); + let failureTitle = "Could not open media menu"; + let progressToast: ReturnType | undefined; + try { + const items: ContextMenuItem[] = []; + if (reference?.kind === "file") { + items.push({ id: "copy-full", label: "Copy full path" }); + if (reference.relativePath) + items.push({ id: "copy-relative", label: "Copy relative path" }); + } else if (reference?.kind === "url") { + items.push({ id: "copy-url", label: "Copy URL" }); + } + if (source.kind === "image") { + const unavailable = source.src === null && source.asset === undefined; + items.push({ id: "save", label: "Save image", disabled: unavailable }); + items.push({ id: "copy-image", label: "Copy image", disabled: unavailable }); + } + if (source.onOpenFile) items.push({ id: "open-file", label: "Open in file viewer" }); + + const action = await api.contextMenu.show(items, position); + if (!action) return; + failureTitle = `Could not ${items.find((item) => item.id === action)?.label.toLowerCase() ?? "complete media action"}`; + const text = + action === "copy-full" && reference?.kind === "file" + ? reference.path + : action === "copy-relative" && reference?.kind === "file" + ? reference.relativePath + : action === "copy-url" && reference?.kind === "url" + ? reference.url + : undefined; + if (text !== undefined) { + await writeTextToClipboard(text, reference?.kind === "file" ? "file path" : "URL"); + toastManager.add({ + type: "success", + title: action === "copy-url" ? "URL copied" : "Path copied", + }); + } else if (action === "open-file") { + source.onOpenFile?.(); + } else if (action === "save" || action === "copy-image") { + progressToast = toastManager.add({ + type: "loading", + title: action === "save" ? "Preparing image download…" : "Copying image…", + }); + await (action === "save" ? save() : copyImage()); + toastManager.update(progressToast, { + type: "success", + title: action === "save" ? "Download started" : "Image copied", + }); + } + } catch (error) { + const toast = stackedThreadToast({ + type: "error", + title: failureTitle, + description: error instanceof Error ? error.message : "The media action failed.", + }); + if (progressToast) toastManager.update(progressToast, toast); + else toastManager.add(toast); + } finally { + menuOpen.current = false; + } + }; + + return ( + + { + if (!hasActions || event.defaultPrevented) return; + event.preventDefault(); + event.stopPropagation(); + const bounds = event.currentTarget.getBoundingClientRect(); + void showMenu( + event.clientX === 0 && event.clientY === 0 + ? { x: bounds.left, y: bounds.bottom } + : { x: event.clientX, y: event.clientY }, + ); + }} + onKeyDown={(event) => { + if ( + !hasActions || + event.defaultPrevented || + !(event.key === "ContextMenu" || (event.shiftKey && event.key === "F10")) + ) + return; + event.preventDefault(); + event.stopPropagation(); + const bounds = event.currentTarget.getBoundingClientRect(); + void showMenu({ x: bounds.left, y: bounds.bottom }); + }} + /> + + {tooltip} + + + ); +} diff --git a/apps/web/src/components/media/MediaVideoPlayer.tsx b/apps/web/src/components/media/MediaVideoPlayer.tsx new file mode 100644 index 000000000000..8f2d75680c14 --- /dev/null +++ b/apps/web/src/components/media/MediaVideoPlayer.tsx @@ -0,0 +1,197 @@ +import { Maximize2Icon, RotateCwIcon, TriangleAlertIcon } from "lucide-react"; +import { useCallback, useEffect, useRef, useState, type CSSProperties } from "react"; + +import { cn } from "../../lib/utils"; +import { prepareVideoFirstFrame } from "../../lib/videoFirstFrame"; +import { Button } from "../ui/button"; +import { OpenMediaLink } from "./OpenMediaLink"; +import { MediaActions, type MediaActionSource } from "./MediaActions"; + +interface MediaVideoPlayerProps { + readonly src: string | null; + readonly label: string; + readonly sourceFailed?: boolean | undefined; + readonly originalUrl?: string | undefined; + readonly revision?: string | null | undefined; + readonly preload?: "visible" | "metadata" | undefined; + readonly className?: string | undefined; + readonly videoClassName?: string | undefined; + readonly style?: CSSProperties | undefined; + readonly copyMarkdown?: string | undefined; + readonly onExpand?: ((src: string) => void) | undefined; + readonly onRetry?: (() => Promise) | undefined; + readonly actionsSource?: MediaActionSource | undefined; +} + +/** Keeps native range streaming and playback state consistent across inline and file previews. */ +export function MediaVideoPlayer({ + src: latestSrc, + label, + sourceFailed = false, + originalUrl, + revision = null, + preload = "visible", + className, + videoClassName, + style, + copyMarkdown, + onExpand, + onRetry, + actionsSource, +}: MediaVideoPlayerProps) { + const videoRef = useRef(null); + const [playbackSource, setPlaybackSource] = useState<{ + src: string; + revision: string | null; + } | null>(null); + const [failedSrc, setFailedSrc] = useState(null); + const [retrying, setRetrying] = useState(false); + const [loadAttempt, setLoadAttempt] = useState(0); + const [preloadedSrc, setPreloadedSrc] = useState(null); + const src = playbackSource?.src ?? latestSrc; + const sourceRevision = playbackSource === null ? revision : playbackSource.revision; + const failed = src !== null ? failedSrc === src : sourceFailed; + + // Re-signing must not reset the playhead. Changed files refresh once playback pauses. + const refreshPausedRevision = useCallback(() => { + const video = videoRef.current; + if (video === null || video.paused || video.ended) { + setPlaybackSource((current) => + current !== null && current.revision !== revision ? null : current, + ); + } + }, [revision]); + useEffect(refreshPausedRevision, [refreshPausedRevision]); + + useEffect(() => { + const video = videoRef.current; + if (!video || preload === "metadata" || preloadedSrc === src) return; + if (typeof IntersectionObserver === "undefined") { + setPreloadedSrc(src); + return; + } + let active = true; + const observer = new IntersectionObserver( + (entries) => { + if (!active || !entries.some((entry) => entry.isIntersecting)) return; + setPreloadedSrc(src); + observer.disconnect(); + }, + { rootMargin: "200px" }, + ); + observer.observe(video); + return () => { + active = false; + observer.disconnect(); + }; + }, [src, preload, preloadedSrc, failed, loadAttempt]); + + useEffect(() => { + const video = videoRef.current; + if (!video) return; + const pauseWhenHidden = () => { + if (document.hidden) video.pause(); + }; + document.addEventListener("visibilitychange", pauseWhenHidden); + return () => { + document.removeEventListener("visibilitychange", pauseWhenHidden); + video.pause(); + }; + }, [src, failed, loadAttempt]); + + const retry = async () => { + if (retrying) return; + setRetrying(true); + try { + await onRetry?.(); + setPlaybackSource(null); + setFailedSrc(null); + setLoadAttempt((current) => current + 1); + } catch { + setFailedSrc(src); + } finally { + setRetrying(false); + } + }; + + const expandButton = + onExpand && src !== null ? ( + + ) : null; + + const player = ( + + {failed ? ( + + + + Video unavailable{label ? ` · ${label}` : ""} + + + {latestSrc !== null || onRetry ? ( + + ) : null} + + {expandButton} + + + ) : src !== null ? ( +
    diff --git a/apps/web/src/components/pullRequest/PullRequestListFilters.test.tsx b/apps/web/src/components/pullRequest/PullRequestListFilters.test.tsx index f1c3013167f7..7ec2629c77b3 100644 --- a/apps/web/src/components/pullRequest/PullRequestListFilters.test.tsx +++ b/apps/web/src/components/pullRequest/PullRequestListFilters.test.tsx @@ -34,7 +34,8 @@ function findLabeledGroup(node: ReactNode, label: string): ReactNode { if (!isValidElement(child)) continue; const props = child.props as { readonly children?: ReactNode; readonly label?: string }; if (props.label === label && typeof child.type === "function") { - return (child.type as (properties: unknown) => ReactNode)(child.props); + const rendered = (child.type as (properties: unknown) => ReactNode)(child.props); + return findLabeledGroup(rendered, label) ?? rendered; } const nested = findLabeledGroup(props.children, label); if (nested !== undefined) return nested; @@ -126,7 +127,7 @@ describe("pull request filters menu", () => { projectEnvironmentId: environmentId, onProject, }); - const radioGroup = findValueChange(view); + const radioGroup = findValueChange(findLabeledGroup(view, "Project")); expect(radioGroup).toBeDefined(); radioGroup?.props.onValueChange(pullRequestProjectKey({ id: projectId, environmentId })); @@ -156,7 +157,7 @@ describe("pull request filters menu", () => { ], onProject, }); - const radioGroup = findValueChange(view); + const radioGroup = findValueChange(findLabeledGroup(view, "Project")); expect(radioGroup).toBeDefined(); radioGroup?.props.onValueChange( diff --git a/apps/web/src/components/pullRequest/PullRequestListFilters.tsx b/apps/web/src/components/pullRequest/PullRequestListFilters.tsx index 67d2d77e4c94..9c3bfbab0c19 100644 --- a/apps/web/src/components/pullRequest/PullRequestListFilters.tsx +++ b/apps/web/src/components/pullRequest/PullRequestListFilters.tsx @@ -18,10 +18,11 @@ import { ListFilterIcon, LoaderIcon, SearchIcon, + TagIcon, + UserRoundIcon, } from "lucide-react"; -import type { ElementType } from "react"; +import { type ElementType, useState } from "react"; -import { cn } from "~/lib/utils"; import { getSourceControlPresentationForKind } from "~/sourceControlPresentation"; import { ProjectFavicon } from "../ProjectFavicon"; import { InputGroup, InputGroupAddon, InputGroupInput } from "../ui/input-group"; @@ -29,27 +30,56 @@ import { Button } from "../ui/button"; import { Menu, + MenuCheckboxItem, MenuGroupLabel, + MenuItem, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuSeparator, + MenuSub, + MenuSubPopup, + MenuSubTrigger, MenuTrigger, } from "../ui/menu"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { + pullRequestLabelColor, + type PullRequestAuthorFacet, + type PullRequestLabelFacet, +} from "./pullRequestList.logic"; +import { PullRequestActorAvatar } from "./pullRequestPresentation"; export interface PullRequestFilterOption { readonly value: Value; readonly label: string; - /** - * Carries the option's own tone, so an icon reads the same here as it does on a row. Left - * uncoloured, which lets the item's selected state stay the thing the eye follows. - */ + /** Uses the option's native icon tone. */ readonly Icon: ElementType<{ className?: string }>; + readonly favicon?: { + readonly environmentId: EnvironmentId; + readonly cwd: string; + }; /** Why it cannot be chosen, carried onto the item as its title. */ readonly unavailable?: string | undefined; } +export function PullRequestFilterOptionIcon({ + option, +}: { + option: PullRequestFilterOption; +}) { + return option.favicon ? ( + + ) : ( + + ); +} + export interface PullRequestExpectedHost { readonly host: string; readonly kind: SourceControlProviderKind; @@ -98,10 +128,8 @@ export function PullRequestSearchInput({ } /** - * Every list filter lives behind the one filter icon so the control row stays two controls - * wide: the search and this. The trigger carries a dot whenever any filter is off its - * default, so a narrowed list is never a mystery. Same menu chrome as the detail panel's - * actions, which also owns its own spacing. + * List narrowings live behind one filter control, separate from sorting. The trigger carries a + * count whenever any filter is off its default, so a narrowed list is never a mystery. */ const ALL_PROJECTS_VALUE = "all"; /** MenuRadioGroup wants a string, so "every host" wears the one value no host can be. */ @@ -169,8 +197,9 @@ function PullRequestFilterRadioGroup({ disabled={option.unavailable !== undefined} > - - {option.label} + + {option.label} + {option.unavailable ? · Unavailable : null} ); @@ -188,7 +217,185 @@ function PullRequestFilterRadioGroup({ ); } +function PullRequestFilterRadioSubmenu({ + label, + value, + options, + onChange, +}: { + label: string; + value: Value; + options: ReadonlyArray>; + onChange: (value: Value) => void; +}) { + const current = options.find((option) => option.value === value) ?? options[0]; + if (!current) return null; + return ( + + + + {label} + + {current.label} + + + + + + + ); +} + +function PullRequestAuthorFilter({ + value, + options, + onChange, +}: { + value: string | undefined; + options: ReadonlyArray; + onChange: (author: string | undefined) => void; +}) { + const [query, setQuery] = useState(""); + const needle = query.trim().toLowerCase(); + const login = value?.toLowerCase() ?? ""; + const selected = options.find((option) => option.actor.login.toLowerCase() === login); + const visible = [ + ...(selected ? [selected] : []), + ...options.filter( + (option) => + option !== selected && + (needle.length === 0 || + option.actor.login.toLowerCase().includes(needle) || + option.actor.name?.toLowerCase().includes(needle)), + ), + ].slice(0, 10); + const select = (next: string) => next.toLowerCase() !== login && onChange(next || undefined); + return ( + + + + Author + + {value ?? "Anyone"} + + + +
    + + + + + setQuery(event.currentTarget.value)} + onKeyDown={(event) => { + if (event.key !== "ArrowDown" && event.key !== "Escape") event.stopPropagation(); + }} + placeholder="Search authors" + aria-label="Search authors" + /> + +
    + + + + + Anyone + + + {visible.map((option) => ( + + + + {option.actor.login} + + {option.mergedCount} merges loaded + + + + ))} + {visible.length === 0 ? No authors found : null} + +
    +
    + ); +} + +function PullRequestLabelFilter({ + value, + options, + onChange, +}: { + value: ReadonlyArray; + options: ReadonlyArray; + onChange: (labels: ReadonlyArray) => void; +}) { + const selected = new Set(value.map((name) => name.toLowerCase())); + const visible = [ + ...value + .filter((name) => !options.some((option) => option.name.toLowerCase() === name.toLowerCase())) + .map((name) => ({ name, color: null, count: 0 })), + ...options, + ]; + return ( + + + + Labels + + {value.length === 0 ? "Any" : `${value.length} selected`} + + + + {visible.length === 0 ? ( + No labels in this view + ) : ( + visible.map((option) => { + const key = option.name.toLowerCase(); + const checked = selected.has(key); + const dot = pullRequestLabelColor(option.color); + return ( + + onChange( + next + ? [...value, option.name] + : value.filter((name) => name.toLowerCase() !== option.name.toLowerCase()), + ) + } + > + + + {option.name} + + {option.count} + + + + ); + }) + )} + + + ); +} + export function PullRequestFiltersMenu({ + onOpenChange, state, stateOptions, onState, @@ -197,6 +404,8 @@ export function PullRequestFiltersMenu({ onInvolvement, filters, onFilters, + authorOptions = [], + labelOptions = [], host, hostOptions, onHost, @@ -209,6 +418,7 @@ export function PullRequestFiltersMenu({ unavailable, onProject, }: { + onOpenChange?: (open: boolean) => void; state: PullRequestListState; stateOptions: ReadonlyArray>; onState: (state: PullRequestListState) => void; @@ -218,6 +428,8 @@ export function PullRequestFiltersMenu({ /** The narrowings beyond state and involvement; an absent field is that group unfiltered. */ filters: PullRequestListFilters; onFilters: (filters: PullRequestListFilters) => void; + authorOptions?: ReadonlyArray; + labelOptions?: ReadonlyArray; host: string | undefined; /** * Includes the "all hosts" entry, whose value is the empty string. With fewer than two real @@ -254,82 +466,119 @@ export function PullRequestFiltersMenu({ /** The environment comes with the project id, since picking a row picks a specific server's copy of it. */ onProject: (projectId: ProjectId | undefined, environmentId: EnvironmentId | undefined) => void; }) { - const filtered = - state !== "open" || - involvement !== "all" || - host !== undefined || - server !== undefined || - projectId !== undefined || - Object.keys(filters).length > 0; - /** - * Rebuilt rather than spread so an unfiltered group leaves the record instead of lingering in - * it as an explicit `undefined`, which the listing input does not accept. - */ - const withFilter = (key: keyof PullRequestListFilters, value: string): PullRequestListFilters => - Object.fromEntries( - Object.entries({ ...filters, [key]: value === UNFILTERED_VALUE ? undefined : value }).filter( - ([, held]) => held !== undefined, - ), - ) as PullRequestListFilters; + const selectedLabels = (filters.labels ?? []).flatMap((group) => group); + const filterCount = [ + state !== "open", + involvement !== "all", + host, + server, + projectId, + filters.draft, + filters.review, + filters.checks, + filters.author, + ...selectedLabels, + ].filter(Boolean).length; + const updateFilters = (next: Partial) => + onFilters( + Object.fromEntries( + Object.entries({ ...filters, ...next }).filter(([, value]) => value !== undefined), + ) as PullRequestListFilters, + ); + const updateFilter = (key: keyof PullRequestListFilters, value: string) => + updateFilters({ + [key]: value === UNFILTERED_VALUE ? undefined : value, + } as Partial); + const projectValue = + projectId === undefined || projectEnvironmentId === undefined + ? ALL_PROJECTS_VALUE + : pullRequestProjectKey({ id: projectId, environmentId: projectEnvironmentId }); + const projectOptions: ReadonlyArray> = [ + { value: ALL_PROJECTS_VALUE, label: "All projects", Icon: LayersIcon }, + ...projects + .toSorted( + (left, right) => + Number(unavailable.has(pullRequestProjectKey(left))) - + Number(unavailable.has(pullRequestProjectKey(right))), + ) + .map((project) => ({ + value: pullRequestProjectKey(project), + label: project.title, + Icon: FolderGit2Icon, + favicon: { environmentId: project.environmentId, cwd: project.workspaceRoot }, + ...(unavailable.has(pullRequestProjectKey(project)) + ? { unavailable: unavailable.get(pullRequestProjectKey(project)) } + : {}), + })), + ]; return ( - + 0 ? "[--control-icon-color:currentColor]" : undefined} variant="outline" - aria-label="Filter pull requests" /> } > - {filtered ? ( - + Filters + {filterCount > 0 ? ( + + {filterCount} + ) : null} - - + - - - updateFilters({ author })} + /> + + updateFilters({ + labels: labels.length === 0 ? undefined : labels.slice(0, 10).map((label) => [label]), + }) + } + /> + onFilters(withFilter("draft", next))} + onChange={(draft) => updateFilter("draft", draft)} /> - - onFilters(withFilter("review", next))} + onChange={(review) => updateFilter("review", review)} /> - - onFilters(withFilter("checks", next))} + onChange={(checks) => updateFilter("checks", checks)} /> {hostOptions.length > 2 ? ( <> - 2 ? ( <> - ) : null} - { - if (next === ALL_PROJECTS_VALUE) { - if (projectId !== undefined) onProject(undefined, undefined); - return; - } - // The value carries both halves, since the id alone cannot tell two servers' rows - // apart once they share one. + { const project = projects.find((candidate) => pullRequestProjectKey(candidate) === next); - if ( - project !== undefined && - (project.id !== projectId || project.environmentId !== projectEnvironmentId) - ) { - onProject(project.id, project.environmentId); - } + if (project) onProject(project.id, project.environmentId); + else if (projectId !== undefined) onProject(undefined, undefined); }} - > - Project - - - - All projects - - - {/* The ones that can be chosen first: a list that opens with three disabled rows reads - as a broken menu rather than as a workspace with three unreadable repositories. */} - {projects - .toSorted( - (left, right) => - Number(unavailable.has(pullRequestProjectKey(left))) - - Number(unavailable.has(pullRequestProjectKey(right))), - ) - .map((project) => { - const reason = unavailable.get(pullRequestProjectKey(project)); - const item = ( - - - - {project.title} - {reason === undefined ? null : ( - - Unavailable - - )} - - - ); - if (reason === undefined) return item; - return ( - - - - {reason} - - - ); - })} - + /> ); diff --git a/apps/web/src/components/pullRequest/PullRequestMarkdown.tsx b/apps/web/src/components/pullRequest/PullRequestMarkdown.tsx index 46aa44dc1289..020bafbd1292 100644 --- a/apps/web/src/components/pullRequest/PullRequestMarkdown.tsx +++ b/apps/web/src/components/pullRequest/PullRequestMarkdown.tsx @@ -1,36 +1,52 @@ import { ExternalLinkIcon, PaperclipIcon, PlayIcon } from "lucide-react"; +import type { EnvironmentId } from "@t3tools/contracts"; +import { createContext, useContext, useMemo } from "react"; +import type { Options as ReactMarkdownOptions } from "react-markdown"; import { cn } from "~/lib/utils"; import ChatMarkdown from "../ChatMarkdown"; -import { splitPullRequestBody } from "./pullRequestMarkdown.logic"; +import { remarkPullRequestAutolinks, splitPullRequestBody } from "./pullRequestMarkdown.logic"; + +export const PullRequestMarkdownContext = createContext(null); /** * A pull request body, rendered with the app's markdown renderer plus a card for each upload * embedded in it, which that renderer drops on the floor. * - * The card links out instead of playing in place, because nothing here can play. A - * `github.com/user-attachments/assets/…` link is a 302 to a signed S3 URL that serves the file - * as uploaded — `video/quicktime` for anything recorded on a Mac, which no Chromium decodes — - * and the desktop window's content policy declares no `media-src`, so media falls back to - * `default-src 'self'` and every remote source is refused before a byte is fetched. A player - * here can only be the box that never fills in; a card that opens the host is a real answer. + * These upload URLs do not identify the media format. The card links to GitHub, where the + * original upload can be opened or downloaded even when its codec cannot play in the client. */ export function PullRequestMarkdown({ text, cwd, + environmentId, className, }: { text: string; cwd: string; + environmentId: EnvironmentId; className?: string; }) { const segments = splitPullRequestBody(text); + const repositoryUrl = useContext(PullRequestMarkdownContext); + const extraRemarkPlugins = useMemo>( + () => (repositoryUrl ? [[remarkPullRequestAutolinks, { repositoryUrl }]] : []), + [repositoryUrl], + ); return (
    {segments.map((segment) => { if (segment.kind === "markdown") { - return ; + return ( + + ); } const isVideo = segment.media === "video"; const Icon = isVideo ? PlayIcon : PaperclipIcon; diff --git a/apps/web/src/components/pullRequest/PullRequestMarkdownEditor.tsx b/apps/web/src/components/pullRequest/PullRequestMarkdownEditor.tsx index f0145c059c0d..d5d2ee0a4757 100644 --- a/apps/web/src/components/pullRequest/PullRequestMarkdownEditor.tsx +++ b/apps/web/src/components/pullRequest/PullRequestMarkdownEditor.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; +import type { EnvironmentId } from "@t3tools/contracts"; import { cn } from "~/lib/utils"; @@ -17,6 +18,7 @@ import { PullRequestMarkdown } from "./PullRequestMarkdown"; export function PullRequestMarkdownEditor({ value, cwd, + environmentId, placeholder, label, saving, @@ -27,6 +29,7 @@ export function PullRequestMarkdownEditor({ }: { readonly value: string; readonly cwd: string; + readonly environmentId: EnvironmentId; readonly placeholder?: string | undefined; readonly label: string; readonly saving: boolean; @@ -81,7 +84,7 @@ export function PullRequestMarkdownEditor({ {empty ? (

    Nothing to preview.

    ) : ( - + )}
    ) : ( diff --git a/apps/web/src/components/pullRequest/PullRequestReviewAnnotation.tsx b/apps/web/src/components/pullRequest/PullRequestReviewAnnotation.tsx index c2e95ee41e12..90a1926d1fad 100644 --- a/apps/web/src/components/pullRequest/PullRequestReviewAnnotation.tsx +++ b/apps/web/src/components/pullRequest/PullRequestReviewAnnotation.tsx @@ -273,6 +273,7 @@ export function ReviewThreadCard({ className="mt-1" value={comment.body} cwd={workspaceRoot} + environmentId={environmentId} label="Edit comment" saving={savingEdit} onSave={(body) => void saveEdit(comment.id, body)} @@ -284,6 +285,7 @@ export function ReviewThreadCard({ className="min-w-0 flex-1 text-sm" text={comment.body} cwd={workspaceRoot} + environmentId={environmentId} /> {canEditComment(comment) ? ( ); diff --git a/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx b/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx index 3594e71b26ea..7d31ec6e4bab 100644 --- a/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx @@ -57,6 +57,7 @@ import { PullRequestMarkdown } from "./PullRequestMarkdown"; import { PullRequestMarkdownEditor } from "./PullRequestMarkdownEditor"; import { PullRequestReactionBar } from "./PullRequestReactions"; import { PullRequestConversationGhost } from "./PullRequestGhosts"; +import { pullRequestLabelColor } from "./pullRequestList.logic"; import { sectionCollapseAnchorScrollTop } from "./pullRequestSummaryScroll.logic"; /** One reviewer, however a host happens to have cased their login this time. */ @@ -64,12 +65,6 @@ function reviewerKey(login: string): string { return login.toLowerCase(); } -/** A host colour only when it is one, so a malformed value falls back to the neutral dot. */ -function labelDotColor(color: string | null): string | null { - const hex = color?.trim().replace(/^#/, "") ?? ""; - return /^[0-9a-fA-F]{6}$/.test(hex) ? `#${hex}` : null; -} - /** The avatar carries the attribution alone; who it is arrives on hover, like the reviewer row. */ function CommentAuthor({ actor }: { actor: PullRequestActor | null }) { const login = actor?.login ?? "ghost"; @@ -94,6 +89,7 @@ function reviewStateLabel(state: string): string { /** What every remark in the conversation needs to be rewritten where it sits. */ interface CommentEditing { readonly cwd: string; + readonly environmentId: EnvironmentId; readonly canEdit: (comment: PullRequestComment) => boolean; readonly editingId: string | null; readonly saving: boolean; @@ -120,6 +116,7 @@ function CommentBody({ className={className} value={comment.body} cwd={editing.cwd} + environmentId={editing.environmentId} label="Edit comment" saving={editing.saving} onSave={(body) => editing.onSave(comment, body)} @@ -129,7 +126,12 @@ function CommentBody({ } return (
    - + {editing.canEdit(comment) ? (
    diff --git a/apps/web/src/components/pullRequest/PullRequestsUnavailableState.test.tsx b/apps/web/src/components/pullRequest/PullRequestsUnavailableState.test.tsx index 1c7c8b6df1f3..12136adbc632 100644 --- a/apps/web/src/components/pullRequest/PullRequestsUnavailableState.test.tsx +++ b/apps/web/src/components/pullRequest/PullRequestsUnavailableState.test.tsx @@ -1,4 +1,5 @@ import { isValidElement, type ReactElement, type ReactNode } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vite-plus/test"; import { PullRequestsUnavailableState } from "./PullRequestsUnavailableState"; @@ -25,8 +26,48 @@ describe("PullRequestsUnavailableState", () => { }); it("retains the retry for transient load failures", () => { - expect( - textOf(PullRequestsUnavailableState({ error: "GitHub did not answer.", onRetry: () => {} })), - ).toContain("Retry"); + const html = renderToStaticMarkup( + {}} + gitHubUrl="https://github.com/pingdotgg/t3code/pull/42" + />, + ); + + expect(html).toContain("Retry"); + expect(html).toContain("Open on GitHub"); + expect(html).toContain('href="https://github.com/pingdotgg/t3code/pull/42"'); + expect(html).toContain('target="_blank"'); + expect(html).toContain('rel="noopener noreferrer"'); + }); + + it("can offer the browser without offering a retry", () => { + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain("Open on GitHub"); + expect(html).not.toContain("Retry"); + }); + + it("can offer a retry without offering GitHub", () => { + const html = renderToStaticMarkup( + {}} />, + ); + + expect(html).toContain("Retry"); + expect(html).not.toContain("Open on GitHub"); + }); + + it("renders no action content without a retry or browser target", () => { + const html = renderToStaticMarkup( + , + ); + + expect(html).not.toContain('data-slot="empty-content"'); + expect(html).not.toContain("href="); }); }); diff --git a/apps/web/src/components/pullRequest/PullRequestsUnavailableState.tsx b/apps/web/src/components/pullRequest/PullRequestsUnavailableState.tsx index 05bc08040e72..70f5c845d2ca 100644 --- a/apps/web/src/components/pullRequest/PullRequestsUnavailableState.tsx +++ b/apps/web/src/components/pullRequest/PullRequestsUnavailableState.tsx @@ -1,4 +1,4 @@ -import { GitPullRequestIcon, RefreshCwIcon } from "lucide-react"; +import { ExternalLinkIcon, GitPullRequestIcon, RefreshCwIcon } from "lucide-react"; import { Button } from "../ui/button"; import { @@ -14,10 +14,12 @@ export function PullRequestsUnavailableState({ title = "Could not load pull requests", error, onRetry, + gitHubUrl, }: { title?: string; error: string; onRetry?: () => void; + gitHubUrl?: string; }) { return ( @@ -30,12 +32,24 @@ export function PullRequestsUnavailableState({ shows its message rather than trying to infer one from the failure text. */} {error} - {onRetry ? ( - - + {onRetry || gitHubUrl ? ( + + {onRetry ? ( + + ) : null} + {gitHubUrl ? ( + + ) : null} ) : null} diff --git a/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts index 0145e6180331..c8110d9ec7a9 100644 --- a/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts @@ -11,6 +11,10 @@ import { matchesPullRequestFilters, matchesPullRequestQuery, parsePullRequestQuery, + pullRequestStatsBatches, + pullRequestStatsKeysToRequest, + pullRequestStatsRefreshBatches, + pullRequestStatsRequestBatches, mergePullRequestDiffStats, narrowPullRequestsToFilters, partitionPullRequestsWithPriority, @@ -18,6 +22,7 @@ import { writePullRequestListSnapshot, rankPullRequestMatches, scorePullRequestMatch, + retainVisiblePullRequestStatsBatches, withDiffStat, resolveProjectScope, resolveQueryEnvironmentIds, @@ -56,6 +61,197 @@ function entry( } as EnvironmentPullRequestEntry; } +describe("visible pull request line-count targets", () => { + it("does not request a row again after its received batch is pruned", () => { + const entries = [entry({ number: 1 }), entry({ number: 2 })]; + const entriesByKey = new Map(entries.map((item) => [pullRequestEntryKey(item), item])); + const firstKey = pullRequestEntryKey(entries[0]!); + const secondKey = pullRequestEntryKey(entries[1]!); + const completedStats = mergePullRequestDiffStats(new Map(), [ + { + environmentId: ENV_1, + projectId: "project-1", + number: 1, + additions: 1, + deletions: 1, + }, + ]); + + const keys = pullRequestStatsKeysToRequest( + entriesByKey, + new Set([firstKey, secondKey]), + [], + completedStats, + ); + expect([...keys]).toEqual([secondKey]); + expect(pullRequestStatsBatches(entriesByKey, keys)[0]?.input.refs).toEqual([ + { projectId: "project-1", repository: "pingdotgg/t3code", number: 2 }, + ]); + }); + + it("drops historical rows after a long scroll so refresh stays bounded to the viewport", () => { + const entries = Array.from({ length: 500 }, (_, index) => entry({ number: index + 1 })); + const entriesByKey = new Map(entries.map((item) => [pullRequestEntryKey(item), item])); + const batches = entries.map( + (item) => pullRequestStatsBatches(entriesByKey, new Set([pullRequestEntryKey(item)]))[0]!, + ); + const visibleKeys = new Set(entries.slice(-12).map(pullRequestEntryKey)); + + const retained = retainVisiblePullRequestStatsBatches(batches, visibleKeys); + expect(retained).toHaveLength(12); + expect(retained.flatMap((batch) => batch.input.refs.map((ref) => ref.number))).toEqual( + entries.slice(-12).map((item) => item.number), + ); + }); + + it("keeps every per-environment batch within the stats contract limit", () => { + const entries = Array.from({ length: 501 }, (_, index) => entry({ number: index + 1 })); + const entriesByKey = new Map(entries.map((item) => [pullRequestEntryKey(item), item])); + const batches = pullRequestStatsBatches( + entriesByKey, + new Set(entries.map(pullRequestEntryKey)), + ); + + expect(batches.map((batch) => batch.input.refs.length)).toEqual([500, 1]); + expect(batches.flatMap((batch) => [...batch.keys])).toEqual(entries.map(pullRequestEntryKey)); + }); + + it("selects visible rows for date modes and every uncached row for size modes", () => { + const entries = [ + entry({ number: 1 }), + entry({ number: 2 }), + entry({ number: 3, environmentId: "env-2" as EnvironmentId }), + ]; + const entriesByKey = new Map(entries.map((item) => [pullRequestEntryKey(item), item])); + const [firstKey, secondKey] = entries.map(pullRequestEntryKey); + const cachedStats = mergePullRequestDiffStats(new Map(), [ + { + environmentId: ENV_1, + projectId: "project-1", + number: 1, + additions: 1, + deletions: 1, + }, + ]); + + const visible = pullRequestStatsRequestBatches({ + entriesByKey, + candidateKeys: new Set([firstKey!, secondKey!]), + policy: "visible", + activeBatches: [], + statsByRow: cachedStats, + }); + expect(visible.flatMap((batch) => batch.input.refs.map((ref) => ref.number))).toEqual([2]); + + const eager = pullRequestStatsRequestBatches({ + entriesByKey, + candidateKeys: new Set([firstKey!]), + policy: "eager", + activeBatches: [], + statsByRow: cachedStats, + }); + expect(eager.map((batch) => batch.environmentId)).toEqual([ENV_1, "env-2"]); + expect(eager.flatMap((batch) => batch.input.refs.map((ref) => ref.number))).toEqual([2, 3]); + }); + + it("refreshes only visible rows unless size sorting needs every loaded row", () => { + const entries = [entry({ number: 1 }), entry({ number: 2 }), entry({ number: 3 })]; + const entriesByKey = new Map(entries.map((item) => [pullRequestEntryKey(item), item])); + const visibleKeys = new Set([pullRequestEntryKey(entries[1]!)]); + const cachedStats = mergePullRequestDiffStats( + new Map(), + entries.map((item) => ({ + environmentId: ENV_1, + projectId: item.projectId, + number: item.number, + additions: item.additions, + deletions: item.deletions, + })), + ); + + const visible = pullRequestStatsRequestBatches({ + entriesByKey, + candidateKeys: visibleKeys, + policy: "visible", + activeBatches: [], + statsByRow: cachedStats, + refresh: true, + }); + expect(visible[0]?.input.refs.map((ref) => ref.number)).toEqual([2]); + + const eager = pullRequestStatsRequestBatches({ + entriesByKey, + candidateKeys: visibleKeys, + policy: "eager", + activeBatches: [], + statsByRow: cachedStats, + refresh: true, + }); + expect(eager[0]?.input.refs.map((ref) => ref.number)).toEqual([1, 2, 3]); + }); + + it("ignores a late refresh after the filter or stats policy changes", () => { + const entries = [entry({ number: 1 }), entry({ number: 2 })]; + const entriesByKey = new Map(entries.map((item) => [pullRequestEntryKey(item), item])); + const visibleKeys = new Set([pullRequestEntryKey(entries[1]!)]); + const requestedScope = { key: "open", policy: "visible" } as const; + const refresh = (currentScope: { key: string; policy: "visible" | "eager" }) => + pullRequestStatsRefreshBatches({ + requestedScope, + currentScope, + entriesByKey, + candidateKeys: visibleKeys, + statsByRow: new Map(), + }); + + expect(refresh(requestedScope)?.[0]?.input.refs.map((ref) => ref.number)).toEqual([2]); + expect(refresh({ key: "closed", policy: "visible" })).toBeNull(); + expect(refresh({ key: "open", policy: "eager" })).toBeNull(); + }); + + it("does not add request batches again while rows are active or cached", () => { + const entries = [entry({ number: 1 }), entry({ number: 2 })]; + const entriesByKey = new Map(entries.map((item) => [pullRequestEntryKey(item), item])); + const first = pullRequestStatsRequestBatches({ + entriesByKey, + candidateKeys: new Set(), + policy: "eager", + activeBatches: [], + statsByRow: new Map(), + }); + + expect( + pullRequestStatsRequestBatches({ + entriesByKey, + candidateKeys: new Set(), + policy: "eager", + activeBatches: first, + statsByRow: new Map(), + }), + ).toEqual([]); + + const cachedStats = mergePullRequestDiffStats( + new Map(), + entries.map((item) => ({ + environmentId: ENV_1, + projectId: item.projectId, + number: item.number, + additions: item.additions, + deletions: item.deletions, + })), + ); + expect( + pullRequestStatsRequestBatches({ + entriesByKey, + candidateKeys: new Set(entries.map(pullRequestEntryKey)), + policy: "visible", + activeBatches: [], + statsByRow: cachedStats, + }), + ).toEqual([]); + }); +}); + describe("pull request involvement filtering", () => { const entries = [ entry({ number: 1, author: { login: "Bilal", name: null, avatarUrl: null } }), diff --git a/apps/web/src/components/pullRequest/pullRequestList.logic.ts b/apps/web/src/components/pullRequest/pullRequestList.logic.ts index d372cabebe22..e18fbe4ae5cc 100644 --- a/apps/web/src/components/pullRequest/pullRequestList.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestList.logic.ts @@ -8,8 +8,11 @@ import { resolvePullRequestAuthorFilter, } from "@t3tools/contracts"; import type { + ProjectId, + PullRequestActor, PullRequestDiffStat, PullRequestInvolvement, + PullRequestLabel, PullRequestListCursors, PullRequestListFilters, PullRequestListState, @@ -40,6 +43,16 @@ export interface PullRequestGroup; } +export interface PullRequestAuthorFacet { + readonly actor: PullRequestActor; + readonly count: number; + readonly mergedCount: number; +} + +export interface PullRequestLabelFacet extends PullRequestLabel { + readonly count: number; +} + /** * The signed-in account per host. Keyed `" "` once a listing spans more than * one environment: two machines can both reach github.com signed in as different people, and a @@ -65,6 +78,59 @@ function normalize(value: string | null | undefined): string | null { return trimmed.length > 0 ? trimmed : null; } +export function pullRequestLabelColor(color: string | null): string | null { + const hex = color?.trim().replace(/^#/, "") ?? ""; + return /^[0-9a-fA-F]{6}$/.test(hex) ? `#${hex}` : null; +} + +export function collectPullRequestListFacets( + entries: ReadonlyArray, + state: PullRequestListState, +) { + const authors = new Map(); + const labels = new Map(); + const uniqueEntries = new Map(entries.map((entry) => [pullRequestEntryKey(entry), entry])); + for (const entry of uniqueEntries.values()) { + const inState = state === "all" || entry.state === state; + if (entry.author !== null) { + const key = normalize(entry.author.login); + if (key !== null) { + const held = authors.get(key); + authors.set(key, { + actor: held?.actor ?? entry.author, + count: (held?.count ?? 0) + Number(inState), + mergedCount: (held?.mergedCount ?? 0) + Number(entry.state === "merged"), + }); + } + } + if (!inState) continue; + for (const label of entry.labels) { + const key = normalize(label.name); + if (key === null) continue; + const held = labels.get(key); + labels.set(key, { + ...label, + name: held?.name ?? label.name, + color: held?.color ?? label.color, + count: (held?.count ?? 0) + 1, + }); + } + } + return { + authors: [...authors.values()] + .filter((author) => author.count > 0) + .toSorted( + (left, right) => + right.mergedCount - left.mergedCount || + right.count - left.count || + left.actor.login.localeCompare(right.actor.login), + ), + labels: [...labels.values()].toSorted( + (left, right) => right.count - left.count || left.name.localeCompare(right.name), + ), + }; +} + /** * The signed-in login for the host a row came from, or null where none was given. Shared by * authorship matching here and by `author:me` resolution wherever a row's own viewer is needed. @@ -364,6 +430,155 @@ export function pullRequestEntryKey(entry: ScopedEntry): string { return `${scope}${entry.host}:${entry.repository}#${entry.number}`; } +export interface PullRequestStatsTarget { + readonly environmentId: EnvironmentId; + readonly input: { + readonly refs: ReadonlyArray<{ + readonly projectId: ProjectId; + readonly repository: string; + readonly number: number; + }>; + }; +} + +export interface PullRequestStatsBatch extends PullRequestStatsTarget { + readonly keys: ReadonlySet; +} + +export type PullRequestStatsPolicy = "visible" | "eager"; + +export interface PullRequestStatsScope { + readonly key: string; + readonly policy: PullRequestStatsPolicy; +} + +const MAX_PULL_REQUEST_STATS_REFS = 500; + +/** Excludes rows already covered by an active batch or the received-count cache. */ +export function pullRequestStatsKeysToRequest( + entriesByKey: ReadonlyMap, + enteredKeys: ReadonlySet, + batches: ReadonlyArray, + statsByRow: ReadonlyMap, +): ReadonlySet { + const requested = new Set(batches.flatMap((batch) => [...batch.keys])); + return new Set( + [...enteredKeys].filter((key) => { + const entry = entriesByKey.get(key); + return ( + entry !== undefined && !requested.has(key) && !statsByRow.has(pullRequestDiffStatKey(entry)) + ); + }), + ); +} + +/** Groups selected rows into bounded, immutable line-count reads per environment. */ +export function pullRequestStatsBatches( + entriesByKey: ReadonlyMap, + keys: ReadonlySet, +): ReadonlyArray { + const byEnvironment = new Map< + EnvironmentId, + Array<{ + readonly key: string; + readonly ref: PullRequestStatsTarget["input"]["refs"][number]; + }> + >(); + for (const key of keys) { + const entry = entriesByKey.get(key); + if (entry === undefined) continue; + const rows = byEnvironment.get(entry.environmentId) ?? []; + rows.push({ + key, + ref: { + projectId: entry.projectId, + repository: entry.repository, + number: entry.number, + }, + }); + byEnvironment.set(entry.environmentId, rows); + } + return [...byEnvironment].flatMap(([environmentId, rows]) => { + const batches: PullRequestStatsBatch[] = []; + for (let index = 0; index < rows.length; index += MAX_PULL_REQUEST_STATS_REFS) { + const batch = rows.slice(index, index + MAX_PULL_REQUEST_STATS_REFS); + batches.push({ + environmentId, + input: { refs: batch.map((row) => row.ref) }, + keys: new Set(batch.map((row) => row.key)), + }); + } + return batches; + }); +} + +/** + * Selects the next immutable stats batches. Size sorting needs every loaded row, while the other + * modes only need rows near the viewport. Normal reads skip active and cached rows; an explicit + * refresh asks for the selected rows again. + */ +export function pullRequestStatsRequestBatches({ + entriesByKey, + candidateKeys, + policy, + activeBatches, + statsByRow, + refresh = false, +}: { + readonly entriesByKey: ReadonlyMap; + readonly candidateKeys: ReadonlySet; + readonly policy: PullRequestStatsPolicy; + readonly activeBatches: ReadonlyArray; + readonly statsByRow: ReadonlyMap; + readonly refresh?: boolean; +}): ReadonlyArray { + const requestedKeys = policy === "eager" ? new Set(entriesByKey.keys()) : candidateKeys; + const keys = refresh + ? requestedKeys + : pullRequestStatsKeysToRequest(entriesByKey, requestedKeys, activeBatches, statsByRow); + return pullRequestStatsBatches(entriesByKey, keys); +} + +/** Ignores a refresh that finished after the list moved to another filter or stats policy. */ +export function pullRequestStatsRefreshBatches({ + requestedScope, + currentScope, + entriesByKey, + candidateKeys, + statsByRow, +}: { + readonly requestedScope: PullRequestStatsScope; + readonly currentScope: PullRequestStatsScope; + readonly entriesByKey: ReadonlyMap; + readonly candidateKeys: ReadonlySet; + readonly statsByRow: ReadonlyMap; +}): ReadonlyArray | null { + if (requestedScope.key !== currentScope.key || requestedScope.policy !== currentScope.policy) { + return null; + } + return pullRequestStatsRequestBatches({ + entriesByKey, + candidateKeys, + policy: requestedScope.policy, + activeBatches: [], + statsByRow, + refresh: true, + }); +} + +/** Drops completed batches once every row in them has left the observer window. */ +export function retainVisiblePullRequestStatsBatches( + batches: ReadonlyArray, + visibleKeys: ReadonlySet, +): ReadonlyArray { + return batches.filter((batch) => { + for (const key of batch.keys) { + if (visibleKeys.has(key)) return true; + } + return false; + }); +} + /** * The priority groups built from the hosts' own answers rather than re-partitioned from the * paginated feed. The feed is sliced by recency, so an older authored or review-requested row @@ -433,13 +648,16 @@ export function mergePullRequestDiffStats( if (stats.length === 0) return previous; const next = new Map(previous); for (const stat of stats) { - next.set(diffStatKey(stat), { additions: stat.additions, deletions: stat.deletions }); + next.set(pullRequestDiffStatKey(stat), { + additions: stat.additions, + deletions: stat.deletions, + }); } return next; } /** A project id only names a project within its own environment, so the key carries both. */ -const diffStatKey = (row: { +export const pullRequestDiffStatKey = (row: { readonly environmentId: string; readonly projectId: string; readonly number: number; @@ -792,6 +1010,6 @@ export function withDiffStat< statsByRow: ReadonlyMap, ): Entry { if (entry.additions !== 0 || entry.deletions !== 0) return entry; - const stat = statsByRow.get(diffStatKey(entry)); + const stat = statsByRow.get(pullRequestDiffStatKey(entry)); return stat === undefined ? entry : { ...entry, ...stat }; } diff --git a/apps/web/src/components/pullRequest/pullRequestMarkdown.logic.ts b/apps/web/src/components/pullRequest/pullRequestMarkdown.logic.ts index e322b595a182..79ae60e928ca 100644 --- a/apps/web/src/components/pullRequest/pullRequestMarkdown.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestMarkdown.logic.ts @@ -1,3 +1,9 @@ +import { + findAndReplaceText, + type MarkdownNode, + type TextMatch, +} from "~/vendor/mdast-find-and-replace"; + /** `id` is positional on purpose: the same attachment can be embedded twice in one body. */ export type PullRequestBodySegment = | { readonly id: string; readonly kind: "markdown"; readonly text: string } @@ -141,3 +147,46 @@ export function splitPullRequestBody(body: string): ReadonlyArray { + findAndReplaceText( + tree, + AUTOLINK_CANDIDATE_PATTERN, + (matched: string, match: TextMatch) => { + const reference = matched.startsWith("#"); + const before = match.input[match.index - 1]; + const after = match.input[match.index + matched.length]; + if ( + (before !== undefined && + (reference + ? AUTOLINK_WORD_CHARACTER_PATTERN.test(before) + : !AUTOLINK_COMMIT_PREFIX_PATTERN.test(before))) || + (after !== undefined && AUTOLINK_WORD_CHARACTER_PATTERN.test(after)) + ) { + return false; + } + return { + type: "link", + url: reference + ? `${repositoryUrl}/issues/${matched.slice(1)}` + : `${repositoryUrl}/commit/${matched}`, + data: { + hProperties: { + dataPullRequestAutolink: reference ? "reference" : "commit", + }, + }, + children: [{ type: "text", value: reference ? matched : matched.slice(0, 7) }], + }; + }, + AUTOLINK_IGNORED_TYPES, + ); + }; +} diff --git a/apps/web/src/components/pullRequest/pullRequestPresentation.tsx b/apps/web/src/components/pullRequest/pullRequestPresentation.tsx index 44184ecdae7e..a43276c3960f 100644 --- a/apps/web/src/components/pullRequest/pullRequestPresentation.tsx +++ b/apps/web/src/components/pullRequest/pullRequestPresentation.tsx @@ -339,17 +339,19 @@ export function PullRequestActorAvatar({ export function PullRequestActorLabel({ actor, className, + labelClassName, tooltip = true, }: { actor: PullRequestActor | null; className?: string; + labelClassName?: string; tooltip?: boolean; }) { const login = actor?.login ?? "ghost"; const label = ( <> - {login} + {login} ); if (!tooltip) { diff --git a/apps/web/src/components/settings/ConnectionsSettings.logic.test.ts b/apps/web/src/components/settings/ConnectionsSettings.logic.test.ts index 290e2daa12b8..74283796a8e2 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.logic.test.ts +++ b/apps/web/src/components/settings/ConnectionsSettings.logic.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it, vi } from "vite-plus/test"; import { applyWslEnableSelection, isQrShareableEndpoint, + isWslSettingsRowVisible, selectQrEndpointOption, } from "./ConnectionsSettings.logic"; @@ -15,6 +16,25 @@ const baseWslState: DesktopWslState = { preflightError: null, }; +describe("isWslSettingsRowVisible", () => { + it("shows the retry row when the WSL state failed to load", () => { + expect(isWslSettingsRowVisible({ state: null, error: "load failed" })).toBe(true); + }); + + it("hides an unavailable and unused WSL snapshot", () => { + expect( + isWslSettingsRowVisible({ + state: { ...baseWslState, available: false, wslOnly: false }, + error: null, + }), + ).toBe(false); + }); + + it("shows an available WSL snapshot", () => { + expect(isWslSettingsRowVisible({ state: baseWslState, error: null })).toBe(true); + }); +}); + describe("applyWslEnableSelection", () => { it("clears WSL-only and updates the distro before enabling both backends", async () => { const calls: Array = []; diff --git a/apps/web/src/components/settings/ConnectionsSettings.logic.ts b/apps/web/src/components/settings/ConnectionsSettings.logic.ts index faa0cb6c7543..d683efab3a4a 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.logic.ts +++ b/apps/web/src/components/settings/ConnectionsSettings.logic.ts @@ -11,6 +11,14 @@ export function isQrShareableEndpoint(endpoint: AdvertisedEndpoint): boolean { return endpoint.status !== "unavailable" && endpoint.reachability !== "loopback"; } +export function isWslSettingsRowVisible(input: { + readonly state: DesktopWslState | null; + readonly error: string | null; +}): boolean { + const { state, error } = input; + return state ? state.available || state.enabled || state.wslOnly : error !== null; +} + export type QrEndpointOption = { /** Unique per endpoint instance (AdvertisedEndpoint.id); safe as a React key. */ readonly id: string; diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 18d1b0f1c924..6ca31aa626f6 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -43,6 +43,7 @@ import { resolveDesktopPairingUrl, resolveHostedPairingUrl } from "./pairingUrls import { applyWslEnableSelection, isQrShareableEndpoint, + isWslSettingsRowVisible, selectQrEndpointOption, } from "./ConnectionsSettings.logic"; import { @@ -1669,7 +1670,7 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b <> {window.desktopBridge ? ( ) : null} {desktopWslError}} control={ @@ -2794,11 +2798,13 @@ export function ConnectionsSettings() { // be stranded on a WSL preference they can't clear, so render a recovery // row that switches back to Windows. When WSL is unavailable AND unused, // there's nothing to recover — keep the section hidden as before. + if (!isWslSettingsRowVisible({ state: desktopWslState, error: desktopWslError })) { + return null; + } if (!desktopWslState.available) { - if (!desktopWslState.enabled && !desktopWslState.wslOnly) return null; return ( ( ( ( {canManageLocalBackend ? ( <> - + {primaryVersionMismatch || primaryServerUpdateState.status !== "idle" ? ( ) : ( - + settings.browserRecordingFrameRate); + const updateSettings = useUpdatePrimarySettings(); + + return ( + + updateSettings({ browserRecordingFrameRate: DEFAULT_BROWSER_RECORDING_FRAME_RATE }) + } + /> + ) : null + } + control={ + + } + /> + ); +} + function AgentBrowserAccessSetting() { const settings = usePrimarySettings(); const updateSettings = useUpdatePrimarySettings(); @@ -461,6 +508,7 @@ export function IntegrationsSettingsPanel() { + ); diff --git a/apps/web/src/components/settings/KeybindingsSettings.tsx b/apps/web/src/components/settings/KeybindingsSettings.tsx index ccbd1f06582e..10a7b7e669fc 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.tsx +++ b/apps/web/src/components/settings/KeybindingsSettings.tsx @@ -3,7 +3,6 @@ import { CircleXIcon, EllipsisIcon, FileJsonIcon, - InfoIcon, MinusIcon, PlusIcon, SearchIcon, @@ -44,12 +43,12 @@ import { serverEnvironment, } from "../../state/server"; import { usePrimaryEnvironment } from "../../state/environments"; +import { Badge } from "../ui/badge"; import { Button } from "../ui/button"; import { Input } from "../ui/input"; import { Kbd, KbdGroup } from "../ui/kbd"; import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu"; import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; -import { ScrollArea } from "../ui/scroll-area"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select"; import { Toggle } from "../ui/toggle"; import { toastManager } from "../ui/toast"; @@ -69,17 +68,23 @@ import { unknownWhenVariables, whenAstToExpression, } from "./KeybindingsSettings.logic"; -import { SettingsPageContainer, SettingsSection } from "./settingsLayout"; +import { SettingsPageContainer, SettingsRow, SettingsSection } from "./settingsLayout"; import { searchableSetting } from "./settingsSearch"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { useAtomCommand } from "../../state/use-atom-command"; function KeybindingPill({ value }: { value: string }) { - const parts = value.split("+"); + // Keys dedupe repeated parts; a literal "+" in a shortcut splits into empty strings. + const seenParts = new Map(); + const parts = value.split("+").map((part) => { + const seen = seenParts.get(part) ?? 0; + seenParts.set(part, seen + 1); + return { part, key: seen === 0 ? part : `${part}-${seen}` }; + }); return ( - {parts.map((part) => ( - + {parts.map(({ part, key }) => ( + {part === "mod" ? navigator.platform.toLowerCase().includes("mac") ? "⌘" @@ -231,19 +236,18 @@ function defaultWhenGroup(operator: BooleanOperator = "and"): KeybindingWhenNode }; } -function UnknownWhenVariableWarning({ - identifiers, +/** Warning glyph whose explanation lives in a tooltip; the one owner of that affordance here. */ +function WarningTooltipIcon({ + label, focusable = true, + className, + children, }: { - identifiers: ReadonlyArray; + label: string; focusable?: boolean; + className?: string | undefined; + children: ReactNode; }) { - if (identifiers.length === 0) return null; - const label = - identifiers.length === 1 - ? `Unknown condition: ${identifiers[0]}` - : `Unknown conditions: ${identifiers.join(", ")}`; - return ( - - + className={cn( + "inline-flex size-5 shrink-0 items-center justify-center rounded-sm text-warning outline-none transition-colors hover:bg-warning/10 focus-visible:ring-[3px] focus-visible:ring-warning/25", + className, + )} + /> } - /> + > + + - T3 Code does not recognize this condition yet. It can still be saved, but it may not match - unless the runtime provides it. + {children} ); } +function UnknownWhenVariableWarning({ + identifiers, + focusable = true, +}: { + identifiers: ReadonlyArray; + focusable?: boolean; +}) { + if (identifiers.length === 0) return null; + const label = + identifiers.length === 1 + ? `Unknown condition: ${identifiers[0]}` + : `Unknown conditions: ${identifiers.join(", ")}`; + + return ( + + T3 Code does not recognize this condition yet. It can still be saved, but it may not match + unless the runtime provides it. + + ); +} + function KeybindingConflictWarning({ labels }: { labels: ReadonlyArray }) { if (labels.length === 0) return null; const description = @@ -273,22 +300,9 @@ function KeybindingConflictWarning({ labels }: { labels: ReadonlyArray } : `Conflicts with ${labels.slice(0, 3).join(", ")}${labels.length > 3 ? ", and more" : ""}.`; return ( - - - - - } - /> - - {description} The most recent matching binding wins when both conditions can apply. - - + + {description} The most recent matching binding wins when both conditions can apply. + ); } @@ -733,32 +747,20 @@ function rowKeybindingTarget(row: KeybindingRow): ServerRemoveKeybindingInput { }; } -function KeybindingTableRow({ +/** Draft state and actions for editing one existing binding; layouts decide how to render it. */ +function useKeybindingRowEditor({ row, allRows, - variables, - isSaving, onSave, - onReset, - onRemove, }: { row: KeybindingRow; allRows: ReadonlyArray; - variables: ReadonlyArray; - isSaving: boolean; onSave: (input: ServerUpsertKeybindingInput) => void; - onReset: (row: KeybindingRow) => void; - onRemove: (row: KeybindingRow) => void; }) { const [draft, setDraft] = useReducer(keybindingRowDraftReducer, row, createKeybindingRowDraft); const { keyDraft, whenDraft, isRecording, isWhenDraftValid } = draft; const whenDraftExpression = whenAstToExpression(whenDraft); const isDirty = keyDraft !== row.key || whenDraftExpression !== row.when; - const displayShortcut = formatShortcutLabel(row.binding.shortcut); - const canReset = row.source === "Custom" && row.defaultKey !== null; - const canRemove = row.source !== "Default"; - const hasRowActions = canReset || canRemove; - const showPill = !isRecording && keyDraft === row.key && row.key.length > 0 && !isDirty; const conflictLabels = keybindingConflictLabels(allRows, { rowId: row.id, key: keyDraft, @@ -786,139 +788,277 @@ function KeybindingTableRow({ setDraft({ keyDraft: next, isRecording: false }); }; + return { + keyDraft, + whenDraft, + isRecording, + isWhenDraftValid, + whenDraftExpression, + isDirty, + conflictLabels, + setDraft, + save, + captureKeybinding, + }; +} + +type KeybindingRowEditor = ReturnType; + +interface KeybindingRowActions { + allRows: ReadonlyArray; + variables: ReadonlyArray; + onSave: (input: ServerUpsertKeybindingInput) => void; + onReset: (row: KeybindingRow) => void; + onRemove: (row: KeybindingRow) => void; +} + +type KeybindingRowProps = KeybindingRowActions & { row: KeybindingRow; isSaving: boolean }; + +/** Shortcut pill that turns into a capture input when clicked, plus Save once the draft changes. */ +function KeybindingKeyControl({ + row, + editor, + isSaving, + pillClassName, +}: { + row: KeybindingRow; + editor: KeybindingRowEditor; + isSaving: boolean; + pillClassName?: string | undefined; +}) { + const { keyDraft, isRecording, isDirty, isWhenDraftValid, setDraft, save, captureKeybinding } = + editor; + const showPill = !isRecording && keyDraft === row.key && row.key.length > 0 && !isDirty; + return ( -
    -
    -
    - - - } - > - {commandLabel(row.command)} - - {row.command} - -
    -
    -
    - {showPill ? ( - - ) : ( - setDraft({ isRecording: true })} - onBlur={() => setDraft({ isRecording: false })} - onChange={(event) => setDraft({ keyDraft: event.currentTarget.value })} - onKeyDown={captureKeybinding} + <> + {isDirty ? ( + + ) : null} + {showPill ? ( + + ) : ( + setDraft({ isRecording: true })} + onBlur={() => setDraft({ isRecording: false })} + onChange={(event) => setDraft({ keyDraft: event.currentTarget.value })} + onKeyDown={captureKeybinding} + /> + )} + + ); +} + +/** Quiet inline trigger showing the when clause; opens the expression builder. */ +function WhenClauseControl({ + label, + expression, + value, + variables, + onChange, + onValidityChange, +}: { + label: string; + expression: string; + value: KeybindingWhenNode | undefined; + variables: ReadonlyArray; + onChange: (value: KeybindingWhenNode | undefined) => void; + onValidityChange: (valid: boolean) => void; +}) { + return ( + + - )} - {isDirty ? ( + } + aria-label={`Edit when clause for ${label}`} + > + {expression || "Always"} + + + + + + + ); +} + +function KeybindingRowMenu({ + row, + isSaving, + onReset, + onRemove, +}: { + row: KeybindingRow; + isSaving: boolean; + onReset: (row: KeybindingRow) => void; + onRemove: (row: KeybindingRow) => void; +}) { + const canReset = row.source === "Custom" && row.defaultKey !== null; + const canRemove = row.source !== "Default"; + if (!canReset && !canRemove) return null; + + return ( + + - {isSaving ? "Saving" : "Save"} - + type="button" + variant="ghost" + size="icon-sm" + className="size-7 text-muted-foreground hover:text-foreground sm:size-7" + disabled={isSaving} + aria-label={`Actions for ${commandLabel(row.command)}`} + /> + } + > + + + + {canReset ? ( + onReset(row)}> + Reset to default + ) : null} -
    -
    - - - {whenDraftExpression || "Always"} - - - - setDraft({ whenDraft: nextWhenDraft })} - onValidityChange={(nextIsValid) => setDraft({ isWhenDraftValid: nextIsValid })} - /> - - -
    -
    - - {hasRowActions ? ( - - - } - > - - - - {canReset ? ( - onReset(row)}> - Reset to default - - ) : null} - {canRemove ? ( - onRemove(row)}> - Remove - - ) : null} - - + {canRemove ? ( + onRemove(row)}> + Remove + ) : null} - {displayShortcut} -
    -
    + +
    ); } -function NewKeybindingTableRow({ - commandOptions, - allRows, +function KeybindingSourceBadge({ source }: { source: KeybindingRow["source"] }) { + if (source === "Default") return null; + return ( + + {source} + + ); +} + +function KeybindingRowTitle({ row }: { row: KeybindingRow }) { + return ( + + }> + {commandLabel(row.command)} + + + {row.command} + + ); +} + +function KeybindingRowWhen({ + row, + editor, variables, - isSaving, - onSave, - onCancel, }: { - commandOptions: ReadonlyArray; - allRows: ReadonlyArray; + row: KeybindingRow; + editor: KeybindingRowEditor; variables: ReadonlyArray; +}) { + return ( + + When + editor.setDraft({ whenDraft })} + onValidityChange={(isWhenDraftValid) => editor.setDraft({ isWhenDraftValid })} + /> + + ); +} + +/** Row actions that stay hidden until the row is hovered or holds focus. */ +function KeybindingHoverRowMenu(props: { + row: KeybindingRow; isSaving: boolean; + onReset: (row: KeybindingRow) => void; + onRemove: (row: KeybindingRow) => void; +}) { + return ( + + + + ); +} + +/** One binding as a settings row: pills flush right, actions fading in beside them on hover. */ +function KeybindingSettingsRow(props: KeybindingRowProps) { + const { row, isSaving, allRows, variables, onSave, onReset, onRemove } = props; + const editor = useKeybindingRowEditor({ row, allRows, onSave }); + + return ( + } + description={} + control={ +
    + + + +
    + } + /> + ); +} + +/** Draft state for a binding that does not exist yet. */ +function useNewKeybindingDraft({ + allRows, + onSave, +}: { + allRows: ReadonlyArray; onSave: (input: ServerUpsertKeybindingInput) => void; - onCancel: () => void; }) { const [commandDraft, setCommandDraft] = useState(""); const [draft, setDraft] = useReducer(keybindingRowDraftReducer, { @@ -935,6 +1075,7 @@ function NewKeybindingTableRow({ when: whenDraftExpression, }); const commandLabelText = commandDraft ? commandLabel(commandDraft) : "new keybinding"; + const canSave = Boolean(commandDraft) && keyDraft.trim().length > 0 && isWhenDraftValid; const save = () => { if (!commandDraft) return; @@ -957,93 +1098,222 @@ function NewKeybindingTableRow({ setDraft({ keyDraft: next, isRecording: false }); }; + return { + commandDraft, + setCommandDraft, + keyDraft, + whenDraft, + whenDraftExpression, + isRecording, + conflictLabels, + commandLabelText, + canSave, + setDraft, + save, + captureKeybinding, + }; +} + +type NewKeybindingDraft = ReturnType; + +interface NewKeybindingProps { + commandOptions: ReadonlyArray; + allRows: ReadonlyArray; + variables: ReadonlyArray; + isSaving: boolean; + onSave: (input: ServerUpsertKeybindingInput) => void; + onCancel: () => void; +} + +function NewKeybindingCommandSelect({ + draft, + commandOptions, + className, +}: { + draft: NewKeybindingDraft; + commandOptions: ReadonlyArray; + className?: string | undefined; +}) { return ( -
    -
    - -
    -
    - setDraft({ isRecording: true })} - onBlur={() => setDraft({ isRecording: false })} - onChange={(event) => setDraft({ keyDraft: event.currentTarget.value })} - onKeyDown={captureKeybinding} + + ); +} + +function NewKeybindingKeyInput({ + draft, + autoFocus = false, + className, +}: { + draft: NewKeybindingDraft; + autoFocus?: boolean; + className?: string | undefined; +}) { + return ( + draft.setDraft({ isRecording: true })} + onBlur={() => draft.setDraft({ isRecording: false })} + onChange={(event) => draft.setDraft({ keyDraft: event.currentTarget.value })} + onKeyDown={draft.captureKeybinding} + /> + ); +} + +function NewKeybindingWhen({ + draft, + variables, +}: { + draft: NewKeybindingDraft; + variables: ReadonlyArray; +}) { + return ( + draft.setDraft({ whenDraft })} + onValidityChange={(isWhenDraftValid) => draft.setDraft({ isWhenDraftValid })} + /> + ); +} + +function NewKeybindingCancelIcon({ + isSaving, + onCancel, +}: { + isSaving: boolean; + onCancel: () => void; +}) { + return ( + + + } + > + + + Cancel + + ); +} + +/** Add-binding form shaped like the binding rows below it. */ +function NewKeybindingSettingsRow(props: NewKeybindingProps) { + const { commandOptions, allRows, variables, isSaving, onSave, onCancel } = props; + const draft = useNewKeybindingDraft({ allRows, onSave }); + + return ( + + When + + + } + control={ +
    + + + + + +
    + } + /> + ); +} + +interface KeybindingsListProps extends KeybindingRowActions { + rows: ReadonlyArray; + commandOptions: ReadonlyArray; + savingCommand: KeybindingCommand | null; + isAddingBinding: boolean; + onCancelAdd: () => void; +} + +/** The add-binding row, one settings row per binding, and the empty state. */ +function KeybindingsList(props: KeybindingsListProps) { + const { rows, commandOptions, savingCommand, isAddingBinding, onCancelAdd, ...rowActions } = + props; + const newProps: NewKeybindingProps = { + commandOptions, + allRows: rows, + variables: rowActions.variables, + isSaving: savingCommand !== null, + onSave: rowActions.onSave, + onCancel: onCancelAdd, + }; + return ( +
    + {isAddingBinding ? : null} + {rows.map((row) => ( + - -
    -
    - - - {whenDraftExpression || "Always"} - - - - setDraft({ whenDraft: nextWhenDraft })} - onValidityChange={(nextIsValid) => setDraft({ isWhenDraftValid: nextIsValid })} - /> - - -
    -
    - - - - } - > - - - Cancel - -
    + ))} + {rows.length === 0 && !isAddingBinding ? ( +
    + No keybindings match your search. +
    + ) : null} +
    + ); +} + +/** Shown in the browser build only; the desktop app receives every shortcut. */ +function BrowserKeybindingNotice() { + return ( +
    + + + Some shortcuts may be claimed by the browser before T3 Code sees them. Use the desktop app + for better keybinding support. +
    ); } @@ -1187,6 +1457,8 @@ export function KeybindingsSettingsPanel() { [saveKeybinding], ); + const cancelAdd = useCallback(() => setIsAddingBinding(false), []); + const bindingsCount = ( {rows.length + (isAddingBinding ? 1 : 0)}{" "} @@ -1194,8 +1466,21 @@ export function KeybindingsSettingsPanel() { ); + const listProps: KeybindingsListProps = { + rows, + allRows: rows, + commandOptions, + variables: whenVariables, + savingCommand, + isAddingBinding, + onCancelAdd: cancelAdd, + onSave: saveKeybinding, + onReset: resetKeybinding, + onRemove: removeKeybinding, + }; + return ( - + } > - {!isElectron ? ( -
    - -

    - Some shortcuts may be claimed by the browser before T3 Code sees them. Use the desktop - app for better keybinding support. -

    -
    - ) : null} + {!isElectron ? : null} - -
    -
    Command
    -
    Keybinding
    -
    When
    -
    Status
    -
    -
    - {isAddingBinding ? ( - setIsAddingBinding(false)} - /> - ) : null} - {rows.map((row) => ( - - ))} - {rows.length === 0 && !isAddingBinding ? ( -
    - No keybindings match your search. -
    - ) : null} -
    -
    +
    ); diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.logic.test.ts b/apps/web/src/components/settings/ProjectSettingsPanel.logic.test.ts new file mode 100644 index 000000000000..8a72b3510ceb --- /dev/null +++ b/apps/web/src/components/settings/ProjectSettingsPanel.logic.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { projectGroupTitleNeedsUpdate } from "./ProjectSettingsPanel.logic"; + +describe("projectGroupTitleNeedsUpdate", () => { + it("updates divergent member titles even when the next title is the derived group label", () => { + expect( + projectGroupTitleNeedsUpdate(["local-title", "remote-title"], "Repository name", true), + ).toBe(true); + }); + + it("skips an untouched blur when the derived label differs from member titles", () => { + expect(projectGroupTitleNeedsUpdate(["repo-slug", "repo-slug"], "Repository Name", false)).toBe( + false, + ); + }); + + it("skips an update when every member already has the next title", () => { + expect(projectGroupTitleNeedsUpdate(["Shared name", "Shared name"], "Shared name", true)).toBe( + false, + ); + }); +}); diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.logic.ts b/apps/web/src/components/settings/ProjectSettingsPanel.logic.ts new file mode 100644 index 000000000000..17ff824099fb --- /dev/null +++ b/apps/web/src/components/settings/ProjectSettingsPanel.logic.ts @@ -0,0 +1,7 @@ +export function projectGroupTitleNeedsUpdate( + memberTitles: ReadonlyArray, + nextTitle: string, + wasEdited: boolean, +): boolean { + return wasEdited && memberTitles.some((title) => title !== nextTitle); +} diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.tsx b/apps/web/src/components/settings/ProjectSettingsPanel.tsx index 6047b8fc48dc..dd9c899430b3 100644 --- a/apps/web/src/components/settings/ProjectSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProjectSettingsPanel.tsx @@ -6,7 +6,7 @@ import { squashAtomCommandFailure, type AtomCommandResult, } from "@t3tools/client-runtime/state/runtime"; -import { scopeProjectRef } from "@t3tools/client-runtime/environment"; +import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; import { AsyncResult } from "effect/unstable/reactivity"; import { deriveProjectGroupingOverrideKey, @@ -113,6 +113,7 @@ import { canPickExternalProjectFavicon, ProjectFaviconPickerDialog, } from "./ProjectFaviconPickerDialog"; +import { projectGroupTitleNeedsUpdate } from "./ProjectSettingsPanel.logic"; export const PROJECT_GROUPING_MODE_LABELS: Record = { repository: "Group by repository", @@ -304,6 +305,7 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { const removeKeybinding = useAtomCommand(serverEnvironment.removeKeybinding, { reportFailure: false, }); + const projectNameEditedRef = useRef(false); const { copyToClipboard: copyPathToClipboard } = useCopyToClipboard<{ path: string }>({ onCopy: ({ path }) => { toastManager.add({ type: "success", title: "Path copied", description: path }); @@ -392,22 +394,31 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { ); const renameGroup = useCallback( - async (nextTitle: string) => { + async (nextTitle: string, wasEdited: boolean) => { const title = nextTitle.trim(); if (!title) { toastManager.add({ type: "warning", title: "Project title cannot be empty" }); return; } - if (title === group.displayName) return; - if (group.memberProjects.every((member) => member.title === title)) return; + if ( + !projectGroupTitleNeedsUpdate( + group.memberProjects.map((member) => member.title), + title, + wasEdited, + ) + ) { + return; + } await updateAllMembers({ title }, "Failed to rename project"); }, - [group.displayName, group.memberProjects, updateAllMembers], + [group.memberProjects, updateAllMembers], ); // ----- default model ----- const storedSelection = representative.defaultModelSelection; const resolvedSelection = resolveDefaultProviderModelSelection(serverProviders, storedSelection); + const resolvedInstanceId = resolvedSelection?.instanceId ?? null; + const resolvedModel = resolvedSelection?.model ?? null; const instanceEntries = useMemo( () => sortProviderInstanceEntries( @@ -416,12 +427,11 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { [serverProviders, settings], ); const modelOptionsByInstance = useMemo( - () => getCustomModelOptionsByInstance(settings, serverProviders), - [serverProviders, settings], - ); - const activeEntry = instanceEntries.find( - (entry) => entry.instanceId === resolvedSelection?.instanceId, + () => + getCustomModelOptionsByInstance(settings, serverProviders, resolvedInstanceId, resolvedModel), + [resolvedInstanceId, resolvedModel, serverProviders, settings], ); + const activeEntry = instanceEntries.find((entry) => entry.instanceId === resolvedInstanceId); const setDefaultModel = useCallback( (selection: ModelSelection | null) => void updateAllMembers({ defaultModelSelection: selection }, "Failed to update default model"), @@ -723,7 +733,10 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { return; } const projectRef = scopeProjectRef(member.environmentId, member.id); - releaseProjectDraftUploads(projectRef); + releaseProjectDraftUploads( + projectRef, + memberThreads.map((thread) => scopeThreadRef(thread.environmentId, thread.id)), + ); const projectDraftThread = draftStore.getDraftThreadByProjectRef(projectRef); if (projectDraftThread) { draftStore.clearDraftThread(projectDraftThread.draftId); @@ -767,8 +780,13 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { className="w-full sm:w-64" aria-label="Project name" defaultValue={group.displayName} + onChange={() => { + projectNameEditedRef.current = true; + }} onBlur={(event) => { - void renameGroup(event.currentTarget.value); + const wasEdited = projectNameEditedRef.current; + projectNameEditedRef.current = false; + void renameGroup(event.currentTarget.value, wasEdited); }} onKeyDown={(event) => { if (event.key === "Enter") event.currentTarget.blur(); diff --git a/apps/web/src/components/settings/ProviderInstanceCard.test.ts b/apps/web/src/components/settings/ProviderInstanceCard.test.ts index 051045b030c3..ed62ff055b09 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.test.ts +++ b/apps/web/src/components/settings/ProviderInstanceCard.test.ts @@ -1,7 +1,14 @@ +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vite-plus/test"; -import type { ServerProviderModel } from "@t3tools/contracts"; +import { + ProviderDriverKind, + ProviderInstanceId, + type ServerProvider, + type ServerProviderModel, +} from "@t3tools/contracts"; -import { deriveProviderModelsForDisplay } from "./ProviderInstanceCard"; +import { deriveProviderModelsForDisplay, ProviderInstanceCard } from "./ProviderInstanceCard"; describe("deriveProviderModelsForDisplay", () => { it("uses current config custom models instead of stale live custom rows", () => { @@ -33,4 +40,83 @@ describe("deriveProviderModelsForDisplay", () => { }).map((model) => model.slug), ).toEqual(["server-model", "kept-custom"]); }); + + it("shows a redacted provider email in the editor header status line", () => { + const instanceId = ProviderInstanceId.make("codex"); + const driver = ProviderDriverKind.make("codex"); + const liveProvider: ServerProvider = { + instanceId, + driver, + enabled: true, + installed: true, + version: "1.0.0", + status: "ready", + auth: { status: "authenticated", email: "developer@example.com" }, + checkedAt: "2026-08-27T12:00:00.000Z", + models: [], + slashCommands: [], + skills: [], + }; + + const markup = renderToStaticMarkup( + createElement(ProviderInstanceCard, { + instanceId, + instance: { driver }, + driverOption: undefined, + liveProvider, + mode: "editor", + onUpdate: () => undefined, + hiddenModels: [], + favoriteModels: [], + modelOrder: [], + onHiddenModelsChange: () => undefined, + onFavoriteModelsChange: () => undefined, + onModelOrderChange: () => undefined, + }), + ); + + expect(markup).toContain("Authenticated as"); + expect(markup).toContain('aria-label="Toggle account email visibility"'); + expect(markup).toContain("blur-[2px]"); + expect(markup).not.toContain("developer@example.com"); + }); + it("surfaces a failed probe message in both the list row and the editor", () => { + const instanceId = ProviderInstanceId.make("codex_work"); + const driver = ProviderDriverKind.make("codex"); + const message = + "Codex app-server provider probe failed: Cannot create Codex shadow home entry 'auth.json' because '/home/me/.codex-t3/work/auth.json' already exists and is not a symlink."; + const liveProvider: ServerProvider = { + instanceId, + driver, + enabled: true, + installed: true, + version: null, + status: "error", + auth: { status: "unknown" }, + checkedAt: "2026-08-28T12:00:00.000Z", + models: [], + slashCommands: [], + skills: [], + message, + }; + const props = { + instanceId, + instance: { driver }, + driverOption: undefined, + liveProvider, + onUpdate: () => undefined, + hiddenModels: [], + favoriteModels: [], + modelOrder: [], + onHiddenModelsChange: () => undefined, + onFavoriteModelsChange: () => undefined, + onModelOrderChange: () => undefined, + } as const; + + for (const mode of ["list", "editor"] as const) { + const markup = renderToStaticMarkup(createElement(ProviderInstanceCard, { ...props, mode })); + expect(markup).toContain("Unavailable"); + expect(markup).toContain("is not a symlink"); + } + }); }); diff --git a/apps/web/src/components/settings/ProviderInstanceCard.tsx b/apps/web/src/components/settings/ProviderInstanceCard.tsx index a663aa90990d..75c0361e9c6a 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.tsx +++ b/apps/web/src/components/settings/ProviderInstanceCard.tsx @@ -2,7 +2,6 @@ import { ArrowUpCircleIcon, - ChevronDownIcon, CopyIcon, DownloadIcon, LoaderIcon, @@ -12,7 +11,7 @@ import { } from "lucide-react"; import * as Arr from "effect/Array"; import * as Result from "effect/Result"; -import { useState, type ReactNode } from "react"; +import { useEffect, useRef, useState, type ReactNode } from "react"; import { isProviderDriverKind, resolveProviderInstanceEnabled, @@ -30,7 +29,6 @@ import { normalizeProviderAccentColor } from "../../providerInstances"; import { Badge } from "../ui/badge"; import { Button } from "../ui/button"; import { Checkbox } from "../ui/checkbox"; -import { Collapsible, CollapsibleContent } from "../ui/collapsible"; import { DraftInput } from "../ui/draft-input"; import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; import { ScrollArea } from "../ui/scroll-area"; @@ -39,9 +37,10 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from ". import { stackedThreadToast, toastManager } from "../ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import type { DriverOption } from "./providerDriverMeta"; +import { providerSettingsTabClassName } from "./providerSettingsTabs"; import { ProviderSettingsForm } from "./ProviderSettingsForm"; import { ProviderModelsSection } from "./ProviderModelsSection"; -import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon"; +import { ProviderInstanceIcon, providerInstanceInitials } from "../chat/ProviderInstanceIcon"; import { ProviderAccentColorPicker } from "./ProviderAccentColorPicker"; import { RedactedSensitiveText } from "./RedactedSensitiveText"; import { @@ -78,6 +77,25 @@ function makeEnvironmentDraftRow( }; } +function providerEnvironmentsEqual( + left: ReadonlyArray, + right: ReadonlyArray, +): boolean { + return ( + left.length === right.length && + left.every((variable, index) => { + const other = right[index]; + return ( + other !== undefined && + variable.name === other.name && + variable.value === other.value && + variable.sensitive === other.sensitive && + variable.valueRedacted === other.valueRedacted + ); + }) + ); +} + /** * Read a string[] at `key` from the opaque config blob, filtering out * non-string entries. Used for `customModels`, which is always typed as @@ -132,25 +150,18 @@ export function deriveProviderModelsForDisplay(input: { return [...serverModels, ...customModels]; } -function ProviderAuthEmail(props: { - readonly email: string | undefined; - readonly prefix?: string; - readonly separator?: boolean; -}) { - const trimmed = props.email?.trim(); - if (!trimmed) return null; +function ProviderAuthEmail(props: { readonly email: string | undefined }) { + const email = props.email?.trim(); + if (!email) return null; return ( - - {props.separator ? · : null} - {props.prefix ? {props.prefix} : null} - - + ); } @@ -161,6 +172,26 @@ function ProviderEnvironmentSection(props: { const [rows, setRows] = useState>(() => props.environment.map(makeEnvironmentDraftRow), ); + const previousEnvironmentRef = useRef(props.environment); + const lastPublishedEnvironmentRef = useRef< + ReadonlyArray | undefined + >(undefined); + + useEffect(() => { + const previousEnvironment = previousEnvironmentRef.current; + const lastPublishedEnvironment = lastPublishedEnvironmentRef.current; + previousEnvironmentRef.current = props.environment; + lastPublishedEnvironmentRef.current = undefined; + if ( + previousEnvironment === props.environment || + providerEnvironmentsEqual(previousEnvironment, props.environment) || + (lastPublishedEnvironment !== undefined && + providerEnvironmentsEqual(lastPublishedEnvironment, props.environment)) + ) { + return; + } + setRows(props.environment.map(makeEnvironmentDraftRow)); + }, [props.environment]); const publishRows = (nextRows: ReadonlyArray) => { const published: ProviderInstanceEnvironmentVariable[] = []; @@ -180,6 +211,7 @@ function ProviderEnvironmentSection(props: { const { id: _id, ...rest } = row; published.push({ ...rest, name }); } + lastPublishedEnvironmentRef.current = published; props.onChange(published); }; @@ -324,8 +356,10 @@ interface ProviderInstanceCardProps { readonly instance: ProviderInstanceConfig; readonly driverOption: DriverOption | undefined; readonly liveProvider: ServerProvider | undefined; - readonly isExpanded: boolean; - readonly onExpandedChange: (open: boolean) => void; + readonly mode: "list" | "editor"; + readonly selected?: boolean | undefined; + readonly onSelect?: (() => void) | undefined; + readonly readOnly?: boolean | undefined; readonly onUpdate: (nextInstance: ProviderInstanceConfig) => void; /** * Pass `undefined` to hide the delete button entirely. Built-in default @@ -353,12 +387,9 @@ interface ProviderInstanceCardProps { } /** - * A single configured provider-instance row in the Providers settings - * section. Used for every row — both the built-in default instance for a - * driver (rendered with `onDelete` omitted) and user-authored custom - * instances (`onDelete` supplied). The only UI difference between the two - * is whether the trash button is visible; every other field (display - * name, config fields, models) behaves identically. + * Renders one provider instance as either a compact selectable list row or + * the full editor shown beside that list. Both modes use the same enabled + * state and provider metadata. * * Behavior notes: * - `liveProvider` is matched by the caller via `instanceId`; when no @@ -379,8 +410,10 @@ export function ProviderInstanceCard({ instance, driverOption, liveProvider, - isExpanded, - onExpandedChange, + mode, + selected = false, + onSelect, + readOnly = false, onUpdate, onDelete, headerAction, @@ -393,21 +426,26 @@ export function ProviderInstanceCard({ onRunUpdate, isUpdating = false, }: ProviderInstanceCardProps) { + const [activeTab, setActiveTab] = useState<"configuration" | "models">("configuration"); const enabled = resolveProviderInstanceEnabled(instance); - // The server-reported status wins when present; otherwise fall back to - // "disabled"/"warning" based on the local `enabled` flag so the dot - // reflects the persisted intent even before the first probe completes. - const statusKey: ProviderStatusKey = - (liveProvider?.status as ProviderStatusKey | undefined) ?? (enabled ? "warning" : "disabled"); + // A locally disabled provider reads "Disabled" with a muted dot even if its + // last server status is stale. Enabled providers use the server status. + const statusKey: ProviderStatusKey = enabled + ? ((liveProvider?.status as ProviderStatusKey | undefined) ?? "warning") + : "disabled"; const statusStyle = PROVIDER_STATUS_STYLES[statusKey]; - const rawSummary = getProviderSummary(liveProvider); - const authEmail = liveProvider?.auth.email; - const hasAuthenticatedEmail = - liveProvider?.auth.status === "authenticated" && Boolean(authEmail?.trim()); - const authenticatedDetail = hasAuthenticatedEmail - ? (liveProvider?.auth.label ?? liveProvider?.auth.type ?? null) - : null; - const summary = rawSummary; + const summary = enabled + ? getProviderSummary(liveProvider) + : { headline: "Disabled", detail: null }; + const authEmail = liveProvider?.auth.email?.trim(); + // The editor header folds the account email into the status line — + // "Authenticated as · " — with the email redacted until its + // reveal toggle is clicked. + const isAuthenticated = enabled && liveProvider?.auth.status === "authenticated"; + const authLabel = + enabled && liveProvider?.auth.status === "authenticated" + ? (liveProvider.auth.label ?? liveProvider.auth.type ?? null) + : null; const versionLabel = getProviderVersionLabel(liveProvider?.version); const versionAdvisory = getProviderVersionAdvisoryPresentation(liveProvider?.versionAdvisory); const updateCommand = versionAdvisory?.updateCommand ?? null; @@ -441,6 +479,7 @@ export function ProviderInstanceCard({ const driverKind: ProviderDriverKind | null = isProviderDriverKind(instance.driver) ? instance.driver : null; + const visibleTab = driverOption === undefined ? "configuration" : activeTab; const customModels = readConfigStringArray(instance.config, "customModels"); // Server-returned models may lag behind settings writes. Treat probe @@ -506,25 +545,21 @@ export function ProviderInstanceCard({ displayName={displayName} accentColor={accentColor} showBadge={Boolean(accentColor)} - statusDotClassName={statusStyle.dot} - indicatorBackground="var(--card)" className="size-5" iconClassName="size-4 text-foreground/80" badgeClassName="right-[-0.125rem] bottom-[-0.125rem] h-3 min-w-3 px-0.5 text-[7px]" /> ) : FallbackIconComponent ? ( - + - ) : ( - + + {providerInstanceInitials(displayName)} + ); const titleHeadNode = ( @@ -576,36 +611,98 @@ export function ProviderInstanceCard({ ); - const authRowNode = ( -

    - {hasAuthenticatedEmail ? ( - <> - Authenticated as - - {authenticatedDetail ? · {authenticatedDetail} : null} - - ) : ( - <> - {summary.headline} - - - )} - {summary.detail ? - {summary.detail} : null} -

    - ); - const versionCodeNode = versionLabel ? ( {versionLabel} ) : null; - return ( -
    -
    -
    -
    -
    - {titleHeadNode} + // Healthy and disabled rows read fine from their text; only trouble gets a dot. + const statusDotNode = + statusKey === "warning" || statusKey === "error" ? ( + + ) : null; + const statusHeadlineNode = {summary.headline}; + // Trouble states carry the server's explanation (a failed probe, a shadow + // home entry that is not a symlink, a missing binary). Show it wherever the + // headline shows so the user can act without opening the editor. + const needsAttention = statusKey === "warning" || statusKey === "error"; + const statusLineClassName = + "flex min-w-0 flex-wrap items-center gap-x-1.5 text-[13px] leading-[1.45] text-muted-foreground/80"; + + if (mode === "list") { + return ( +
    + + + updateEnabled(Boolean(checked))} + aria-label={`Enable ${displayName}`} + /> + +
    + ); + } + + return ( +
    +
    +
    +
    + {titleHeadNode} + {versionCodeNode} + {/* + Only the write actions go inert on read-only sessions; the + status line below keeps its email reveal clickable. + */} + {versionAdvisory ? ( - + } /> @@ -651,7 +748,7 @@ export function ProviderInstanceCard({
    - {authRowNode} -
    -
    - - updateEnabled(Boolean(checked))} - aria-label={`Enable ${displayName}`} - /> +
    +

    + {statusDotNode} + {isAuthenticated && authEmail ? ( + <> + Authenticated as + + {authLabel ? · {authLabel} : null} + + ) : ( + statusHeadlineNode + )} + {summary.detail && !needsAttention ? · {summary.detail} : null} +

    + {summary.detail && needsAttention ? ( +

    + {summary.detail} +

    + ) : null}
    - - -
    +
    + + {driverOption !== undefined ? ( + + ) : null} +
    + +
    +
    ); } diff --git a/apps/web/src/components/settings/ProviderModelsSection.tsx b/apps/web/src/components/settings/ProviderModelsSection.tsx index 9a42961d13ee..a4e8b7e7a9a7 100644 --- a/apps/web/src/components/settings/ProviderModelsSection.tsx +++ b/apps/web/src/components/settings/ProviderModelsSection.tsx @@ -185,12 +185,15 @@ export function ProviderModelsSection({ }; return ( -
    +
    Models
    {models.length} model{models.length === 1 ? "" : "s"} available.
    -
    +
    {orderedModels.map((model, index) => { const caps = model.capabilities; const capLabels: string[] = []; diff --git a/apps/web/src/components/settings/ProviderSettingsForm.test.ts b/apps/web/src/components/settings/ProviderSettingsForm.test.ts index ea8712a87eb5..af460824c171 100644 --- a/apps/web/src/components/settings/ProviderSettingsForm.test.ts +++ b/apps/web/src/components/settings/ProviderSettingsForm.test.ts @@ -37,6 +37,18 @@ describe("ProviderSettingsForm helpers", () => { }); }); + it("shows the auto-compaction threshold for Claude providers", () => { + const claude = DRIVER_OPTION_BY_VALUE[ProviderDriverKind.make("claudeAgent")]; + expect(claude).toBeDefined(); + + expect(deriveProviderSettingsFields(claude!).map((field) => field.key)).toEqual([ + "binaryPath", + "homePath", + "autoCompactWindow", + "launchArgs", + ]); + }); + it("preserves unknown config keys while omitting empty configurable fields", () => { const opencode = DRIVER_OPTION_BY_VALUE[ProviderDriverKind.make("opencode")]; expect(opencode).toBeDefined(); diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.environment.test.tsx b/apps/web/src/components/settings/ProviderSettingsPanel.environment.test.tsx index 2b304378ae91..41c38c3c819c 100644 --- a/apps/web/src/components/settings/ProviderSettingsPanel.environment.test.tsx +++ b/apps/web/src/components/settings/ProviderSettingsPanel.environment.test.tsx @@ -31,18 +31,32 @@ const settingsState = vi.hoisted(() => ({ updateSettings: vi.fn(), })); +const settingsSearchState = vi.hoisted(() => ({ + targetId: null as string | null, + effects: [] as Array<() => void>, +})); + vi.mock("react", async (importOriginal) => { const actual = await importOriginal(); const { reactHookHarness } = await import("../../test/reactHookHarness"); return { ...actual, useCallback: reactHookHarness.useCallback, + useEffect: (effect: () => void) => settingsSearchState.effects.push(effect), useMemo: reactHookHarness.useMemo, useRef: reactHookHarness.useRef, useState: reactHookHarness.useState, }; }); +vi.mock("./settingsLayout", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useSettingsSearchTargetId: () => settingsSearchState.targetId, + }; +}); + vi.mock("react/compiler-runtime", async () => { const { reactHookHarness } = await import("../../test/reactHookHarness"); return { c: reactHookHarness.useMemoCache }; @@ -127,6 +141,21 @@ function renderPanel(options?: { }) as ReactElement>; } +function isAddProviderButton(element: ReactElement>): boolean { + return element.props["aria-label"] === "Add provider"; +} + +function findAdvancedPanel(panel: ReactElement>) { + return visitElements( + panel, + (element) => element.props.className === "mt-1" && typeof element.props.open === "boolean", + ); +} + +function flushEffects(): void { + for (const effect of settingsSearchState.effects.splice(0)) effect(); +} + async function flushPromises(): Promise { await Promise.resolve(); await Promise.resolve(); @@ -140,6 +169,8 @@ describe("EnvironmentProviderSettings routing", () => { settingsState.readEnvironmentIds = []; settingsState.updateEnvironmentIds = []; settingsState.updateSettings.mockReset(); + settingsSearchState.targetId = null; + settingsSearchState.effects = []; commands.refresh.mockReset().mockResolvedValue({ _tag: "Success" }); commands.updateProvider.mockReset().mockResolvedValue({ _tag: "Success" }); }); @@ -178,24 +209,44 @@ describe("EnvironmentProviderSettings routing", () => { }); }); - it("renders the provider layout inert with a limited-permissions notice when read only", () => { + it("keeps provider selection available while write controls are read only", () => { + settingsState.value = { + ...DEFAULT_UNIFIED_SETTINGS, + providerInstances: { + [customId]: { + driver: ProviderDriverKind.make("codex"), + enabled: true, + }, + }, + }; atoms.providers = [provider()]; - const panel = renderPanel({ readOnly: true }); + let panel = renderPanel({ readOnly: true }); const inertWrapper = visitElements(panel, (element) => element.props.inert === true); expect(inertWrapper).not.toBeNull(); - const providerCard = visitElements(panel, (element) => element.props.instanceId === codexId); - expect(providerCard).not.toBeNull(); + + const customRow = visitElements( + panel, + (element) => element.props.instanceId === customId && element.props.mode === "list", + ); + expect(customRow?.props.readOnly).toBe(true); + expect(customRow?.props.onSelect).toBeTypeOf("function"); + (customRow?.props.onSelect as (() => void) | undefined)?.(); + + panel = renderPanel({ readOnly: true }); + const customEditor = visitElements( + panel, + (element) => element.props.instanceId === customId && element.props.mode === "editor", + ); + expect(customEditor).not.toBeNull(); const notice = visitElements(panel, (element) => element.props.title === "Limited permissions"); expect(notice).not.toBeNull(); - expect( - visitElements(panel, (element) => element.props["aria-label"] === "Add provider instance"), - ).toBeNull(); expect( visitElements(panel, (element) => element.props["aria-label"] === "Refresh provider status"), ).toBeNull(); + expect(visitElements(panel, isAddProviderButton)).toBeNull(); }); it("keeps the editable layout interactive when not read only", () => { @@ -205,6 +256,21 @@ describe("EnvironmentProviderSettings routing", () => { expect( visitElements(panel, (element) => element.props.title === "Limited permissions"), ).toBeNull(); + expect( + visitElements(panel, (element) => element.props["aria-label"] === "Refresh provider status"), + ).not.toBeNull(); + expect(visitElements(panel, isAddProviderButton)).not.toBeNull(); + }); + + it("opens Advanced when search targets the provider health interval", () => { + settingsSearchState.targetId = "provider-health-check-interval"; + let panel = renderPanel(); + + expect(findAdvancedPanel(panel)?.props.open).toBe(false); + flushEffects(); + + panel = renderPanel(); + expect(findAdvancedPanel(panel)?.props.open).toBe(true); }); it("deletes and resets provider configuration without erasing shared preferences", () => { @@ -225,8 +291,17 @@ describe("EnvironmentProviderSettings routing", () => { }, favorites: [{ provider: customId, model: "favorite" }], }; - const panel = renderPanel(); - const customCard = visitElements(panel, (element) => element.props.instanceId === customId); + let panel = renderPanel(); + const customRow = visitElements( + panel, + (element) => element.props.instanceId === customId && element.props.mode === "list", + ); + (customRow?.props.onSelect as (() => void) | undefined)?.(); + panel = renderPanel(); + const customCard = visitElements( + panel, + (element) => element.props.instanceId === customId && element.props.mode === "editor", + ); expect(customCard).not.toBeNull(); (customCard?.props.onDelete as (() => void) | undefined)?.(); @@ -237,7 +312,16 @@ describe("EnvironmentProviderSettings routing", () => { }); settingsState.updateSettings.mockClear(); - const defaultCard = visitElements(panel, (element) => element.props.instanceId === codexId); + const defaultRow = visitElements( + panel, + (element) => element.props.instanceId === codexId && element.props.mode === "list", + ); + (defaultRow?.props.onSelect as (() => void) | undefined)?.(); + panel = renderPanel(); + const defaultCard = visitElements( + panel, + (element) => element.props.instanceId === codexId && element.props.mode === "editor", + ); const resetAction = defaultCard?.props.headerAction; const resetButton = visitElements( resetAction, diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.logic.test.ts b/apps/web/src/components/settings/ProviderSettingsPanel.logic.test.ts index bf558f5a4d66..c04db646a47e 100644 --- a/apps/web/src/components/settings/ProviderSettingsPanel.logic.test.ts +++ b/apps/web/src/components/settings/ProviderSettingsPanel.logic.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from "vite-plus/test"; import { buildProviderEnvironmentOptions, classifyProviderEnvironmentAccess, + isProviderSettingsEnvironmentAvailable, resolvePrimaryOperateAccess, resolveRemoteOperateAccess, resolveSelectedProviderEnvironmentId, @@ -20,6 +21,27 @@ const environments = [ ] as const; describe("provider environment selection", () => { + it("requires a connected environment with server config for searchable provider settings", () => { + expect( + isProviderSettingsEnvironmentAvailable({ + connectionPhase: "connected", + hasServerConfig: true, + }), + ).toBe(true); + expect( + isProviderSettingsEnvironmentAvailable({ + connectionPhase: "reconnecting", + hasServerConfig: true, + }), + ).toBe(false); + expect( + isProviderSettingsEnvironmentAvailable({ + connectionPhase: "connected", + hasServerConfig: false, + }), + ).toBe(false); + }); + it("sorts the primary environment first and the rest by label", () => { expect( buildProviderEnvironmentOptions(environments, primaryId).map( diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.logic.ts b/apps/web/src/components/settings/ProviderSettingsPanel.logic.ts index 1c7dac391f6a..b415b5f69b07 100644 --- a/apps/web/src/components/settings/ProviderSettingsPanel.logic.ts +++ b/apps/web/src/components/settings/ProviderSettingsPanel.logic.ts @@ -10,6 +10,13 @@ export interface ProviderEnvironmentOptionLike { readonly label: string; } +export function isProviderSettingsEnvironmentAvailable(input: { + readonly connectionPhase: EnvironmentConnectionPhase; + readonly hasServerConfig: boolean; +}): boolean { + return input.connectionPhase === "connected" && input.hasServerConfig; +} + export function buildProviderEnvironmentOptions( environments: ReadonlyArray, primaryEnvironmentId: EnvironmentId | null, diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.tsx b/apps/web/src/components/settings/ProviderSettingsPanel.tsx index 3a38a91e2265..bafff6f48b8b 100644 --- a/apps/web/src/components/settings/ProviderSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProviderSettingsPanel.tsx @@ -24,6 +24,7 @@ import * as Duration from "effect/Duration"; import * as Equal from "effect/Equal"; import * as Result from "effect/Result"; import { + ChevronDownIcon, CloudIcon, LaptopIcon, LoaderIcon, @@ -32,7 +33,7 @@ import { RefreshCwIcon, TerminalIcon, } from "lucide-react"; -import { useCallback, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { isDesktopLocalConnectionTarget } from "../../connection/desktopLocal"; import { isElectron } from "../../env"; @@ -62,6 +63,7 @@ import { type ProviderUpdateCandidate, } from "../ProviderUpdateLaunchNotification.logic"; import { Button } from "../ui/button"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "../ui/collapsible"; import { NumberField, NumberFieldDecrement, @@ -69,11 +71,13 @@ import { NumberFieldIncrement, NumberFieldInput, } from "../ui/number-field"; -import { stackedThreadToast, toastManager } from "../ui/toast"; +import { ScrollArea } from "../ui/scroll-area"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { stackedThreadToast, toastManager } from "../ui/toast"; import { AddProviderInstanceDialog } from "./AddProviderInstanceDialog"; import { ProviderInstanceCard } from "./ProviderInstanceCard"; import { DRIVER_OPTIONS, getDriverOption } from "./providerDriverMeta"; +import { providerSettingsTabClassName } from "./providerSettingsTabs"; import { searchableSetting } from "./settingsSearch"; import { backgroundActivityOverrideSettings, @@ -89,10 +93,12 @@ import { SettingsRow, SettingsSection, useRelativeTimeTick, + useSettingsSearchTargetId, } from "./settingsLayout"; import { buildProviderEnvironmentOptions, classifyProviderEnvironmentAccess, + isProviderSettingsEnvironmentAvailable, type ProviderEnvironmentAccess, type ProviderOperateAccess, resolvePrimaryOperateAccess, @@ -165,9 +171,11 @@ function providerEnvironmentDetail(environment: EnvironmentPresentation): string function EnvironmentUnavailableRow({ environment, access, + deviceTabs, }: { readonly environment: EnvironmentPresentation; readonly access: Exclude; + readonly deviceTabs?: ReactNode; }) { const isLoading = access.kind === "loading"; const title = isLoading @@ -183,15 +191,25 @@ function EnvironmentUnavailableRow({ // No spinner: this state can persist indefinitely for a wedged device, and a // continuously repainting animation would run the whole time. return ( - + + {deviceTabs} ); } export function ProviderSettingsPanel() { + return ( + + + + ); +} + +function ProviderSettingsPanelContent() { const { environments, isReady } = useEnvironments(); const primaryEnvironmentId = usePrimaryEnvironmentId(); + const searchTargetId = useSettingsSearchTargetId(); const options = useMemo( () => buildProviderEnvironmentOptions(environments, primaryEnvironmentId), [environments, primaryEnvironmentId], @@ -209,66 +227,88 @@ export function ProviderSettingsPanel() { ); const selectedEnvironment = options.find((environment) => environment.environmentId === effectiveEnvironmentId) ?? null; + const selectedEnvironmentCanRenderSettings = + selectedEnvironment !== null && + isProviderSettingsEnvironmentAvailable({ + connectionPhase: selectedEnvironment.connection.phase, + hasServerConfig: selectedEnvironment.serverConfig !== null, + }); + const searchableEnvironmentId = options.find((environment) => + isProviderSettingsEnvironmentAvailable({ + connectionPhase: environment.connection.phase, + hasServerConfig: environment.serverConfig !== null, + }), + )?.environmentId; + useEffect(() => { + if ( + searchTargetId === searchableSetting("provider-health-check-interval").id && + !selectedEnvironmentCanRenderSettings && + searchableEnvironmentId !== undefined + ) { + setSelectedEnvironmentId(searchableEnvironmentId); + } + }, [searchTargetId, searchableEnvironmentId, selectedEnvironmentCanRenderSettings]); const onlyPrimaryDevice = options.length === 1 && options[0]?.entry.target._tag === "PrimaryConnectionTarget"; - - return ( - - {!onlyPrimaryDevice ? ( - - {options.length === 0 ? ( - // The catalog hydrates asynchronously, so an empty list before it is - // ready means "not loaded yet", not "nothing is connected". - - ) : ( -
    - {options.map((environment) => { - const Icon = providerEnvironmentIcon(environment); - const selected = environment.environmentId === effectiveEnvironmentId; - const statusText = connectionStatusText(environment.connection); - return ( - - ); - })} -
    - )} + + } + /> + + {detail} · {statusText} + + + ); + })} +
    + + ) : null; + + return ( + <> + {options.length === 0 ? ( + + ) : null} @@ -276,33 +316,46 @@ export function ProviderSettingsPanel() { ) : null} - + ); } function SelectedEnvironmentProviderSettings({ environment, + deviceTabs, }: { readonly environment: EnvironmentPresentation; + readonly deviceTabs?: ReactNode; }) { const isPrimary = environment.entry.target._tag === "PrimaryConnectionTarget"; if (isPrimary) { // The desktop app owns its primary server outright; a browser session // checks the scopes its cookie session was granted. if (isElectron) { - return ; + return ( + + ); } - return ; + return ( + + ); } - return ; + return ; } function PrimarySessionGatedProviderSettings({ environment, + deviceTabs, }: { readonly environment: EnvironmentPresentation; + readonly deviceTabs?: ReactNode; }) { const primarySessionState = usePrimarySessionState(); const operateAccess = resolvePrimaryOperateAccess({ @@ -312,13 +365,21 @@ function PrimarySessionGatedProviderSettings({ isPending: primarySessionState.isPending, hasError: primarySessionState.error !== null, }); - return ; + return ( + + ); } function RemoteSessionGatedProviderSettings({ environment, + deviceTabs, }: { readonly environment: EnvironmentPresentation; + readonly deviceTabs?: ReactNode; }) { const sessionState = useEnvironmentSessionState(environment.environmentId); const operateAccess = resolveRemoteOperateAccess({ @@ -326,15 +387,23 @@ function RemoteSessionGatedProviderSettings({ isPending: sessionState.isPending, hasError: sessionState.hasError, }); - return ; + return ( + + ); } function AccessGatedProviderSettings({ environment, operateAccess, + deviceTabs, }: { readonly environment: EnvironmentPresentation; readonly operateAccess: ProviderOperateAccess; + readonly deviceTabs?: ReactNode; }) { const access = classifyProviderEnvironmentAccess({ connectionPhase: environment.connection.phase, @@ -342,13 +411,20 @@ function AccessGatedProviderSettings({ operateAccess, }); if (access.kind !== "editable" && access.kind !== "read-only") { - return ; + return ( + + ); } return ( ); } @@ -357,14 +433,17 @@ export function EnvironmentProviderSettings({ environmentId, environmentLabel, readOnly = false, + deviceTabs, }: { readonly environmentId: EnvironmentId; readonly environmentLabel: string; + readonly deviceTabs?: ReactNode; /** - * Render the full provider layout, greyed out and inert, when this session's - * credential lacks `orchestration:operate` on the environment. Showing the - * real configuration keeps the view honest; disabling interaction keeps - * every one of its writes from being offered and then rejected. + * Grey out and freeze every write control when this session's credential + * lacks `orchestration:operate` on the environment. Selecting providers and + * opening Advanced still work so the real configuration stays readable; + * switches, forms, and the health interval are inert so no write is + * offered and then rejected. */ readonly readOnly?: boolean; }) { @@ -380,13 +459,21 @@ export function EnvironmentProviderSettings({ }); const [isRefreshingProviders, setIsRefreshingProviders] = useState(false); const [isAddInstanceDialogOpen, setIsAddInstanceDialogOpen] = useState(false); + const [selectedInstanceId, setSelectedInstanceId] = useState(null); + const [advancedOpen, setAdvancedOpen] = useState(false); + const searchTargetId = useSettingsSearchTargetId(); const [updatingProviderDrivers, setUpdatingProviderDrivers] = useState< ReadonlySet >(() => new Set()); - const [openInstanceDetails, setOpenInstanceDetails] = useState>({}); const refreshingRef = useRef(false); const updatingDriversRef = useRef>(new Set()); + useEffect(() => { + if (searchTargetId === searchableSetting("provider-health-check-interval").id) { + setAdvancedOpen(true); + } + }, [searchTargetId]); + const providerUpdateCandidates = useMemo( () => collectProviderUpdateCandidates(serverProviders), [serverProviders], @@ -577,6 +664,8 @@ export function EnvironmentProviderSettings({ } } + const selectedRow = rows.find((row) => row.instanceId === selectedInstanceId) ?? rows[0] ?? null; + const updateProviderInstance = ( row: InstanceRow, next: ProviderInstanceConfig, @@ -666,13 +755,119 @@ export function EnvironmentProviderSettings({ }); }; + const renderProviderInstance = (row: InstanceRow, mode: "list" | "editor") => { + const driverOption = getDriverOption(row.driver); + const liveProvider = serverProviders.find( + (candidate) => candidate.instanceId === row.instanceId, + ); + const updateCandidate = liveProvider + ? providerUpdateCandidateByInstanceId.get(liveProvider.instanceId) + : undefined; + const isDriverUpdateRunning = + updateCandidate !== undefined && + (updatingProviderDrivers.has(updateCandidate.driver) || + serverProviders.some( + (provider) => + provider.driver === updateCandidate.driver && isProviderUpdateActive(provider), + )); + const showInlineUpdateButton = + updateCandidate !== undefined && + hasOneClickUpdateProviderCandidate(updateCandidate, serverProviders); + const canRunInlineUpdate = + updateCandidate !== undefined && + canOneClickUpdateProviderCandidate(updateCandidate, serverProviders) && + !updatingProviderDrivers.has(updateCandidate.driver); + const modelPreferences = settings.providerModelPreferences?.[row.instanceId] ?? { + hiddenModels: [], + modelOrder: [], + }; + const favoriteModels = Arr.filterMap(settings.favorites ?? [], (favorite) => + favorite.provider === row.instanceId ? Result.succeed(favorite.model) : Result.failVoid, + ); + const resetLabel = driverOption?.label ?? String(row.driver); + + return ( + setSelectedInstanceId(row.instanceId) : undefined} + readOnly={readOnly} + onUpdate={(next) => { + const wasEnabled = resolveProviderInstanceEnabled(row.instance); + const isDisabling = next.enabled === false && wasEnabled; + const shouldClearTextGen = isDisabling && textGenInstanceId === row.instanceId; + updateProviderInstance( + row, + next, + shouldClearTextGen + ? { + textGenerationModelSelection: + DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection, + } + : undefined, + ); + }} + onDelete={ + mode === "editor" && !row.isDefault + ? () => deleteProviderInstance(row.instanceId) + : undefined + } + headerAction={ + mode === "editor" && row.isDefault && row.isDirty ? ( + resetDefaultInstance(row.driver)} + /> + ) : null + } + hiddenModels={modelPreferences.hiddenModels} + favoriteModels={favoriteModels} + modelOrder={modelPreferences.modelOrder} + onHiddenModelsChange={(hiddenModels) => + updateProviderModelPreferences(row.instanceId, { + ...modelPreferences, + hiddenModels, + }) + } + onFavoriteModelsChange={(next) => updateProviderFavoriteModels(row.instanceId, next)} + onModelOrderChange={(modelOrder) => + updateProviderModelPreferences(row.instanceId, { + ...modelPreferences, + modelOrder, + }) + } + onRunUpdate={ + mode === "editor" && showInlineUpdateButton && updateCandidate + ? () => { + if (canRunInlineUpdate) void runProviderUpdate(updateCandidate); + } + : undefined + } + isUpdating={mode === "editor" && showInlineUpdateButton ? isDriverUpdateRunning : undefined} + /> + ); + }; + return ( <> - +
    + {/* + The 11px size must sit on this flex item, not just the span + inside: the item's line box is struck from its own font size, + and an inherited 16px strut hangs the smaller text below the + vertical center of the row. + */} + + + {!readOnly ? ( <> @@ -681,14 +876,19 @@ export function EnvironmentProviderSettings({ } /> - Add provider instance + Refresh provider status void refreshProviders()} - aria-label="Refresh provider status" + onClick={() => setIsAddInstanceDialogOpen(true)} + aria-label="Add provider" > - {isRefreshingProviders ? ( - - ) : ( - - )} + } /> - Refresh provider status + Add provider ) : null}
    } > + {deviceTabs} {readOnly ? ( ) : null} -
    - - Health check interval - - This interval is configured here, then the shared Background activity policy - decides whether provider probes may run when the timer fires. Custom intervals - appear as Advanced in General settings. - - - } - description="Refresh provider availability, versions, auth state, and model metadata in the background. Set this to 0 seconds to rely on manual refreshes." - resetAction={ - providerHealthRefreshIntervalSeconds !== - defaultProviderHealthRefreshIntervalSeconds ? ( - - updateSettings( - backgroundActivityOverrideSettings( - settings.backgroundActivity, - resolvedBackgroundActivity, - { - providerHealthRefreshInterval: undefined, - }, - ), - ) - } - /> - ) : null - } - control={ -
    - - updateSettings( - backgroundActivityOverrideSettings( - settings.backgroundActivity, - resolvedBackgroundActivity, - { - providerHealthRefreshInterval: Duration.seconds( - normalizeIntervalSeconds(value), - ), - }, - ), - ) - } - > - - - - - - - seconds -
    - } - /> +
    +
    +
    + +
    + {rows.map((row) => ( +
    + {renderProviderInstance(row, "list")} +
    + ))} +
    +
    +
    - {rows.map((row) => { - const driverOption = getDriverOption(row.driver); - const liveProvider = serverProviders.find( - (candidate) => candidate.instanceId === row.instanceId, - ); - const updateCandidate = liveProvider - ? providerUpdateCandidateByInstanceId.get(liveProvider.instanceId) - : undefined; - const isDriverUpdateRunning = - updateCandidate !== undefined && - (updatingProviderDrivers.has(updateCandidate.driver) || - serverProviders.some( - (provider) => - provider.driver === updateCandidate.driver && isProviderUpdateActive(provider), - )); - const showInlineUpdateButton = - updateCandidate !== undefined && - hasOneClickUpdateProviderCandidate(updateCandidate, serverProviders); - const canRunInlineUpdate = - updateCandidate !== undefined && - canOneClickUpdateProviderCandidate(updateCandidate, serverProviders) && - !updatingProviderDrivers.has(updateCandidate.driver); - const modelPreferences = settings.providerModelPreferences?.[row.instanceId] ?? { - hiddenModels: [], - modelOrder: [], - }; - const favoriteModels = Arr.filterMap(settings.favorites ?? [], (favorite) => - favorite.provider === row.instanceId - ? Result.succeed(favorite.model) - : Result.failVoid, - ); - const resetLabel = driverOption?.label ?? String(row.driver); - const headerAction = - row.isDefault && row.isDirty ? ( - resetDefaultInstance(row.driver)} - /> - ) : null; - return ( - - setOpenInstanceDetails((existing) => ({ - ...existing, - [row.instanceId]: open, - })) - } - onUpdate={(next) => { - const wasEnabled = resolveProviderInstanceEnabled(row.instance); - const isDisabling = next.enabled === false && wasEnabled; - const shouldClearTextGen = isDisabling && textGenInstanceId === row.instanceId; - if (shouldClearTextGen) { - updateProviderInstance(row, next, { - textGenerationModelSelection: - DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection, - }); - } else { - updateProviderInstance(row, next); +
    + {selectedRow ? ( + renderProviderInstance(selectedRow, "editor") + ) : ( +
    No providers configured.
    + )} +
    +
    + + + + + Advanced + + +
    + + {searchableSetting("provider-health-check-interval").title} + + This interval is configured here, then the shared Background activity policy + decides whether provider probes may run when the timer fires. Custom + intervals appear as Advanced in General settings. + + } - }} - onDelete={row.isDefault ? undefined : () => deleteProviderInstance(row.instanceId)} - headerAction={headerAction} - hiddenModels={modelPreferences.hiddenModels} - favoriteModels={favoriteModels} - modelOrder={modelPreferences.modelOrder} - onHiddenModelsChange={(hiddenModels) => - updateProviderModelPreferences(row.instanceId, { - ...modelPreferences, - hiddenModels, - }) - } - onFavoriteModelsChange={(favoriteModels) => - updateProviderFavoriteModels(row.instanceId, favoriteModels) - } - onModelOrderChange={(modelOrder) => - updateProviderModelPreferences(row.instanceId, { - ...modelPreferences, - modelOrder, - }) - } - onRunUpdate={ - showInlineUpdateButton && updateCandidate - ? () => { - if (!canRunInlineUpdate) { - return; + description="Refresh availability, versions, auth state, and models in the background. 0 seconds turns background checks off." + resetAction={ + providerHealthRefreshIntervalSeconds !== + defaultProviderHealthRefreshIntervalSeconds ? ( + + updateSettings( + backgroundActivityOverrideSettings( + settings.backgroundActivity, + resolvedBackgroundActivity, + { providerHealthRefreshInterval: undefined }, + ), + ) } - void runProviderUpdate(updateCandidate); - } - : undefined - } - isUpdating={showInlineUpdateButton ? isDriverUpdateRunning : undefined} - /> - ); - })} + /> + ) : null + } + control={ +
    + + updateSettings( + backgroundActivityOverrideSettings( + settings.backgroundActivity, + resolvedBackgroundActivity, + { + providerHealthRefreshInterval: Duration.seconds( + normalizeIntervalSeconds(value), + ), + }, + ), + ) + } + > + + + + + + + seconds +
    + } + /> +
    +
    +
    diff --git a/apps/web/src/components/settings/SettingsPanels.logic.test.ts b/apps/web/src/components/settings/SettingsPanels.logic.test.ts index 5c715eb4eb25..490d248595f9 100644 --- a/apps/web/src/components/settings/SettingsPanels.logic.test.ts +++ b/apps/web/src/components/settings/SettingsPanels.logic.test.ts @@ -268,9 +268,16 @@ describe("getChangedBrowserSettingLabels", () => { browserDefaultViewport: { _tag: "freeform", width: 900, height: 600 }, browserDefaultZoomFactor: 1.5, browserDefaultAppearance: "dark", + browserRecordingFrameRate: 60, browserAutoShowFloatingPreview: !DEFAULT_UNIFIED_SETTINGS.browserAutoShowFloatingPreview, }), - ).toEqual(["Browser viewport", "Browser zoom", "Browser appearance", "Floating preview"]); + ).toEqual([ + "Browser viewport", + "Browser zoom", + "Browser appearance", + "Recording frame rate", + "Floating preview", + ]); }); }); diff --git a/apps/web/src/components/settings/SettingsPanels.logic.ts b/apps/web/src/components/settings/SettingsPanels.logic.ts index a5d5d9958498..331391dbc464 100644 --- a/apps/web/src/components/settings/SettingsPanels.logic.ts +++ b/apps/web/src/components/settings/SettingsPanels.logic.ts @@ -114,6 +114,7 @@ export type BrowserDefaultSettings = Pick< | "browserDefaultViewport" | "browserDefaultZoomFactor" | "browserDefaultAppearance" + | "browserRecordingFrameRate" | "browserAutoShowFloatingPreview" >; @@ -151,6 +152,9 @@ export function getChangedBrowserSettingLabels(settings: BrowserDefaultSettings) ...(settings.browserDefaultAppearance !== DEFAULT_UNIFIED_SETTINGS.browserDefaultAppearance ? ["Browser appearance"] : []), + ...(settings.browserRecordingFrameRate !== DEFAULT_UNIFIED_SETTINGS.browserRecordingFrameRate + ? ["Recording frame rate"] + : []), ...(settings.browserAutoShowFloatingPreview !== DEFAULT_UNIFIED_SETTINGS.browserAutoShowFloatingPreview ? ["Floating preview"] diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index e77c05549265..68dadd7cb6a3 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -34,6 +34,7 @@ import { MIN_PROMPT_FONT_SIZE, MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS, MIN_TERMINAL_FONT_SIZE, + type QuitConfirmationMode, } from "@t3tools/contracts/settings"; import { resolveServerBackgroundActivitySettings } from "@t3tools/shared/backgroundActivitySettings"; import { createModelSelection } from "@t3tools/shared/model"; @@ -79,7 +80,11 @@ import { } from "../../providerInstances"; import { ensureLocalApi, readLocalApi } from "../../localApi"; import { isMacPlatform } from "../../lib/utils"; -import { primaryServerObservabilityAtom, primaryServerProvidersAtom } from "../../state/server"; +import { + primaryServerConfigAtom, + primaryServerObservabilityAtom, + primaryServerProvidersAtom, +} from "../../state/server"; import { useProjects } from "../../state/entities"; import { useArchivedThreadSnapshots } from "../../lib/archivedThreadsState"; import { formatRelativeTimeLabel } from "../../timestampFormat"; @@ -159,6 +164,12 @@ const TIMESTAMP_FORMAT_LABELS = { "24-hour": "24-hour", } as const; +const QUIT_CONFIRMATION_MODE_LABELS: Record = { + direct: "Direct", + hold: "Hold", + "double-click": "Double press", +}; + const BACKGROUND_ACTIVITY_PROFILE_LABELS: Record = { balanced: "Balanced", performance: "Performance", @@ -377,7 +388,7 @@ function AboutVersionSection() { render={ , - ); - - expect(html).toContain("rounded-[var(--control-radius)]"); - expect(html).toContain("[--control-icon-color:var(--contrast-muted-foreground)]"); - expect(html).toContain("text-[var(--control-icon-color)]"); - expect(html).not.toContain("opacity-80"); - }); - - it("keeps compact icon buttons square at every breakpoint", () => { - const html = renderToStaticMarkup( - , - ); - - expect(html).toContain("size-7"); - expect(html).toContain("sm:size-6"); - }); - - it("owns shared compact and micro control geometry", () => { - const compact = renderToStaticMarkup(); - const microLabel = renderToStaticMarkup( - , - ); - const micro = renderToStaticMarkup( - , - ); - - expect(compact).toContain("h-7"); - expect(compact).toContain("rounded-md"); - expect(microLabel).toContain("text-[11px]"); - expect(microLabel).toContain("sm:text-[11px]"); - expect(microLabel).toContain("sm:[&_svg:not([class*='size-'])]:size-3"); - expect(micro).toContain("size-5"); - expect(micro).toContain("rounded-sm"); - expect(micro).toContain("text-muted-foreground"); - }); -}); diff --git a/apps/web/src/components/ui/card.tsx b/apps/web/src/components/ui/card.tsx deleted file mode 100644 index f1428d13d909..000000000000 --- a/apps/web/src/components/ui/card.tsx +++ /dev/null @@ -1,196 +0,0 @@ -"use client"; - -import { mergeProps } from "@base-ui/react/merge-props"; -import { useRender } from "@base-ui/react/use-render"; - -import { cn } from "~/lib/utils"; - -function Card({ className, render, ...props }: useRender.ComponentProps<"div">) { - const defaultProps = { - className: cn( - "relative flex flex-col rounded-2xl border bg-card not-dark:bg-clip-padding text-card-foreground shadow-xs/5 before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-2xl)-1px)] before:shadow-[0_1px_--theme(--color-black/4%)] dark:before:shadow-[0_-1px_--theme(--color-white/6%)]", - className, - ), - "data-slot": "card", - }; - - return useRender({ - defaultTagName: "div", - props: mergeProps<"div">(defaultProps, props), - render, - }); -} - -function CardFrame({ className, render, ...props }: useRender.ComponentProps<"div">) { - const defaultProps = { - className: cn( - "[--clip-top:-1rem] [--clip-bottom:-1rem] *:data-[slot=card]:first:[--clip-top:1px] *:data-[slot=card]:last:[--clip-bottom:1px] flex flex-col relative rounded-2xl border bg-card before:bg-muted/72 not-dark:bg-clip-padding text-card-foreground shadow-xs/5 before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-2xl)-1px)] before:shadow-[0_1px_--theme(--color-black/4%)] dark:before:shadow-[0_-1px_--theme(--color-white/6%)] *:data-[slot=card]:-m-px *:not-last:data-[slot=card]:rounded-b-xl *:not-last:data-[slot=card]:before:rounded-b-[calc(var(--radius-xl)-1px)] *:not-first:data-[slot=card]:rounded-t-xl *:not-first:data-[slot=card]:before:rounded-t-[calc(var(--radius-xl)-1px)] *:data-[slot=card]:[clip-path:inset(var(--clip-top)_1px_var(--clip-bottom)_1px_round_calc(var(--radius-2xl)-1px))] *:data-[slot=card]:shadow-none *:data-[slot=card]:before:hidden *:data-[slot=card]:bg-clip-padding", - className, - ), - "data-slot": "card-frame", - }; - - return useRender({ - defaultTagName: "div", - props: mergeProps<"div">(defaultProps, props), - render, - }); -} - -function CardFrameHeader({ className, render, ...props }: useRender.ComponentProps<"div">) { - const defaultProps = { - className: cn("relative flex flex-col px-6 py-4", className), - "data-slot": "card-frame-header", - }; - - return useRender({ - defaultTagName: "div", - props: mergeProps<"div">(defaultProps, props), - render, - }); -} - -function CardFrameTitle({ className, render, ...props }: useRender.ComponentProps<"div">) { - const defaultProps = { - className: cn("font-semibold text-sm", className), - "data-slot": "card-frame-title", - }; - - return useRender({ - defaultTagName: "div", - props: mergeProps<"div">(defaultProps, props), - render, - }); -} - -function CardFrameDescription({ className, render, ...props }: useRender.ComponentProps<"div">) { - const defaultProps = { - className: cn("text-muted-foreground text-sm", className), - "data-slot": "card-frame-description", - }; - - return useRender({ - defaultTagName: "div", - props: mergeProps<"div">(defaultProps, props), - render, - }); -} - -function CardFrameFooter({ className, render, ...props }: useRender.ComponentProps<"div">) { - const defaultProps = { - className: cn("px-6 py-4", className), - "data-slot": "card-frame-footer", - }; - - return useRender({ - defaultTagName: "div", - props: mergeProps<"div">(defaultProps, props), - render, - }); -} - -function CardHeader({ className, render, ...props }: useRender.ComponentProps<"div">) { - const defaultProps = { - className: cn( - "grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 p-6 in-[[data-slot=card]:has(>[data-slot=card-panel])]:pb-4 has-data-[slot=card-action]:grid-cols-[1fr_auto]", - className, - ), - "data-slot": "card-header", - }; - - return useRender({ - defaultTagName: "div", - props: mergeProps<"div">(defaultProps, props), - render, - }); -} - -function CardTitle({ className, render, ...props }: useRender.ComponentProps<"div">) { - const defaultProps = { - className: cn("font-semibold text-lg leading-none", className), - "data-slot": "card-title", - }; - - return useRender({ - defaultTagName: "div", - props: mergeProps<"div">(defaultProps, props), - render, - }); -} - -function CardDescription({ className, render, ...props }: useRender.ComponentProps<"div">) { - const defaultProps = { - className: cn("text-muted-foreground text-sm", className), - "data-slot": "card-description", - }; - - return useRender({ - defaultTagName: "div", - props: mergeProps<"div">(defaultProps, props), - render, - }); -} - -function CardAction({ className, render, ...props }: useRender.ComponentProps<"div">) { - const defaultProps = { - className: cn( - "col-start-2 row-span-2 row-start-1 self-start justify-self-end inline-flex", - className, - ), - "data-slot": "card-action", - }; - - return useRender({ - defaultTagName: "div", - props: mergeProps<"div">(defaultProps, props), - render, - }); -} - -function CardPanel({ className, render, ...props }: useRender.ComponentProps<"div">) { - const defaultProps = { - className: cn( - "flex-1 p-6 in-[[data-slot=card]:has(>[data-slot=card-header]:not(.border-b))]:pt-0 in-[[data-slot=card]:has(>[data-slot=card-footer]:not(.border-t))]:pb-0", - className, - ), - "data-slot": "card-panel", - }; - - return useRender({ - defaultTagName: "div", - props: mergeProps<"div">(defaultProps, props), - render, - }); -} - -function CardFooter({ className, render, ...props }: useRender.ComponentProps<"div">) { - const defaultProps = { - className: cn( - "flex items-center p-6 in-[[data-slot=card]:has(>[data-slot=card-panel])]:pt-4", - className, - ), - "data-slot": "card-footer", - }; - - return useRender({ - defaultTagName: "div", - props: mergeProps<"div">(defaultProps, props), - render, - }); -} - -export { - Card, - CardFrame, - CardFrameHeader, - CardFrameTitle, - CardFrameDescription, - CardFrameFooter, - CardAction, - CardDescription, - CardFooter, - CardHeader, - CardPanel, - CardPanel as CardContent, - CardTitle, -}; diff --git a/apps/web/src/components/ui/combobox.tsx b/apps/web/src/components/ui/combobox.tsx index cf3a46142ad2..e2253100d2d8 100644 --- a/apps/web/src/components/ui/combobox.tsx +++ b/apps/web/src/components/ui/combobox.tsx @@ -175,7 +175,7 @@ function ComboboxPopup({ )} > diff --git a/apps/web/src/components/ui/field.tsx b/apps/web/src/components/ui/field.tsx deleted file mode 100644 index 1bf65b6c8049..000000000000 --- a/apps/web/src/components/ui/field.tsx +++ /dev/null @@ -1,59 +0,0 @@ -"use client"; - -import { Field as FieldPrimitive } from "@base-ui/react/field"; - -import { cn } from "~/lib/utils"; - -function Field({ className, ...props }: FieldPrimitive.Root.Props) { - return ( - - ); -} - -function FieldLabel({ className, ...props }: FieldPrimitive.Label.Props) { - return ( - - ); -} - -function FieldItem({ className, ...props }: FieldPrimitive.Item.Props) { - return ( - - ); -} - -function FieldDescription({ className, ...props }: FieldPrimitive.Description.Props) { - return ( - - ); -} - -function FieldError({ className, ...props }: FieldPrimitive.Error.Props) { - return ( - - ); -} - -const FieldControl = FieldPrimitive.Control; -const FieldValidity = FieldPrimitive.Validity; - -export { Field, FieldLabel, FieldDescription, FieldError, FieldControl, FieldItem, FieldValidity }; diff --git a/apps/web/src/components/ui/fieldset.tsx b/apps/web/src/components/ui/fieldset.tsx deleted file mode 100644 index 23763b982426..000000000000 --- a/apps/web/src/components/ui/fieldset.tsx +++ /dev/null @@ -1,26 +0,0 @@ -"use client"; - -import { Fieldset as FieldsetPrimitive } from "@base-ui/react/fieldset"; - -import { cn } from "~/lib/utils"; - -function Fieldset({ className, ...props }: FieldsetPrimitive.Root.Props) { - return ( - - ); -} -function FieldsetLegend({ className, ...props }: FieldsetPrimitive.Legend.Props) { - return ( - - ); -} - -export { Fieldset, FieldsetLegend }; diff --git a/apps/web/src/components/ui/form.tsx b/apps/web/src/components/ui/form.tsx deleted file mode 100644 index 641fc2ee6270..000000000000 --- a/apps/web/src/components/ui/form.tsx +++ /dev/null @@ -1,17 +0,0 @@ -"use client"; - -import { Form as FormPrimitive } from "@base-ui/react/form"; - -import { cn } from "~/lib/utils"; - -function Form({ className, ...props }: FormPrimitive.Props) { - return ( - - ); -} - -export { Form }; diff --git a/apps/web/src/components/ui/menu.test.tsx b/apps/web/src/components/ui/menu.test.tsx deleted file mode 100644 index 079d2a1794b8..000000000000 --- a/apps/web/src/components/ui/menu.test.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { renderToStaticMarkup } from "react-dom/server"; -import { describe, expect, it } from "vite-plus/test"; - -import { Menu, MenuRadioGroup, MenuRadioItem } from "./menu"; - -describe("menu radio item geometry", () => { - it("keeps radio-item icons on the same text grid as menu items", () => { - const html = renderToStaticMarkup( - - - - - - Merge - - - - , - ); - - expect(html).toContain("-mx-0.5"); - }); -}); diff --git a/apps/web/src/components/ui/toggle-group.test.tsx b/apps/web/src/components/ui/toggle-group.test.tsx deleted file mode 100644 index c2403c9f1175..000000000000 --- a/apps/web/src/components/ui/toggle-group.test.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { renderToStaticMarkup } from "react-dom/server"; -import { describe, expect, it } from "vite-plus/test"; - -import { Toggle, ToggleGroup } from "./toggle-group"; - -describe("toggle group segmented defaults", () => { - it("derives the segmented item size from the variant", () => { - const html = renderToStaticMarkup( - - A - , - ); - - expect(html.match(/data-size="segmented"/g)).toHaveLength(2); - expect(html).toContain("h-6"); - expect(html).toContain("dark:hover:bg-input/32"); - expect(html).toContain("dark:data-pressed:bg-input/72"); - }); -}); diff --git a/apps/web/src/components/usage/UsagePage.test.tsx b/apps/web/src/components/usage/UsagePage.test.tsx index 373e8a07c79e..8e86b521e890 100644 --- a/apps/web/src/components/usage/UsagePage.test.tsx +++ b/apps/web/src/components/usage/UsagePage.test.tsx @@ -5,6 +5,8 @@ import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; const testState = vi.hoisted(() => ({ useUsage: vi.fn(), + metric: "cost" as "cost" | "tokens", + breakdown: "time" as "model" | "time", })); vi.mock("react", async (importOriginal) => { @@ -24,9 +26,11 @@ vi.mock("react", async (importOriginal) => { untilTime: "2026-08-11T12:37:00.000Z", }, } - : initial === "model" - ? "time" - : initial, + : initial === "cost" + ? testState.metric + : initial === "model" + ? testState.breakdown + : initial, vi.fn(), ]), }; @@ -72,10 +76,40 @@ const providerTotals = (codex: number, claude: number) => ["claude", { costUsd: claude, totalTokens: claude * 1_000 }], ] as const); +const modelTotals = Object.freeze([ + { + model: "expensive-model", + provider: "claude" as const, + costUsd: 10, + totalTokens: 100, + records: 1, + costShare: 10 / 16, + }, + { + model: "token-heavy-model", + provider: "codex" as const, + costUsd: 5, + totalTokens: 1_000, + records: 1, + costShare: 5 / 16, + }, + { + model: "token-heavy-cheaper-model", + provider: "codex" as const, + costUsd: 1, + totalTokens: 1_000, + records: 1, + costShare: 1 / 16, + }, +]); + beforeEach(() => { + testState.metric = "cost"; + testState.breakdown = "time"; testState.useUsage.mockReturnValue({ merged: { ...mergeUsage([], USAGE_CONTRACT_VERSION), + models: modelTotals, hourly: [ { day: "2026-08-10", @@ -110,4 +144,39 @@ describe("UsagePage hourly breakdown", () => { expect(body).toContain("$13.00"); expect(body.indexOf("$11.00")).toBeLessThan(body.indexOf("$13.00")); }); + + it("keeps chronological ordering when the token metric is selected", () => { + testState.metric = "tokens"; + + const markup = renderToStaticMarkup(); + const body = markup.match(/(.*?)<\/tbody>/)?.[1] ?? ""; + + expect(body).toMatch(/\$11\.00.*\$13\.00/); + }); +}); + +describe("UsagePage model breakdown", () => { + it("sorts models by cost when the cost metric is selected", () => { + testState.breakdown = "model"; + + const markup = renderToStaticMarkup(); + const body = markup.match(/(.*?)<\/tbody>/)?.[1] ?? ""; + + expect(body).toMatch(/expensive-model.*token-heavy-model.*token-heavy-cheaper-model/); + }); + + it("sorts models by token usage when the token metric is selected", () => { + testState.metric = "tokens"; + testState.breakdown = "model"; + + const markup = renderToStaticMarkup(); + const body = markup.match(/(.*?)<\/tbody>/)?.[1] ?? ""; + + expect(body).toMatch(/token-heavy-model.*token-heavy-cheaper-model.*expensive-model/); + expect(modelTotals.map((model) => model.model)).toEqual([ + "expensive-model", + "token-heavy-model", + "token-heavy-cheaper-model", + ]); + }); }); diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index 0c9239a78093..7474bb9d6120 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -74,6 +74,15 @@ export function UsagePage() { () => (isPast24Hours ? merged.hourly : merged.daily).toReversed(), [isPast24Hours, merged.daily, merged.hourly], ); + const breakdownModels = useMemo( + () => + breakdown === "model" && metric === "tokens" + ? merged.models.toSorted( + (left, right) => right.totalTokens - left.totalTokens || right.costUsd - left.costUsd, + ) + : merged.models, + [breakdown, merged.models, metric], + ); const activeProviders = useMemo(() => providersWithUsage(merged.providers), [merged.providers]); const timeValueColumnWidth = `${60 / (activeProviders.length + 2)}%`; @@ -212,7 +221,7 @@ export function UsagePage() { staleEnvironments={merged.staleEnvironments} /> -
    +
    @@ -350,14 +359,14 @@ export function UsagePage() { - {merged.models.length === 0 ? ( + {breakdownModels.length === 0 ? ( No activity in this window. ) : ( - merged.models.map((model) => ( + breakdownModels.map((model) => ( -
    +
    @@ -592,12 +601,8 @@ function UsageSkeleton() {
    - - + +
    @@ -611,7 +616,7 @@ function UsageSkeleton() {
    -
    +
    @@ -623,12 +628,20 @@ function UsageSkeleton() { (label) => (
    {label} -
    +
    ), )}
    + +
    +
    +

    Breakdown

    +
    +
    +
    +
    ); } diff --git a/apps/web/src/components/usage/UsageProviderChart.test.ts b/apps/web/src/components/usage/UsageProviderChart.test.ts index 09961213b0c4..a4114cfdfb57 100644 --- a/apps/web/src/components/usage/UsageProviderChart.test.ts +++ b/apps/web/src/components/usage/UsageProviderChart.test.ts @@ -86,6 +86,7 @@ describe("buildDayColumns", () => { expect(first?.bands).toEqual([ { provider: "codex", value: 10 }, { provider: "claude", value: 20 }, + { provider: "grok", value: 0 }, ]); }); diff --git a/apps/web/src/components/usage/usageProviders.ts b/apps/web/src/components/usage/usageProviders.ts index 79f62a65a811..efad95e531ad 100644 --- a/apps/web/src/components/usage/usageProviders.ts +++ b/apps/web/src/components/usage/usageProviders.ts @@ -1,6 +1,6 @@ import type { UsageProviderKind } from "@t3tools/contracts"; -import { ClaudeAI, type Icon, OpenAI } from "../Icons"; +import { ClaudeAI, GrokIcon, type Icon, OpenAI } from "../Icons"; type UsageProviderPresentation = { readonly label: string; @@ -24,6 +24,12 @@ export const PROVIDER_PRESENTATION = { color: "#d97757", mark: ClaudeAI, }, + grok: { + label: "Grok Build", + // Contrast-aware neutral between the Codex series and muted chart chrome. + color: "color-mix(in oklab, var(--contrast-foreground) 72%, var(--background))", + mark: GrokIcon, + }, } satisfies Record; /** Stable provider reading order across charts, summaries, tables, and hover rows. */ diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index 20c6603f773b..4e8e9d200bf6 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -11,6 +11,7 @@ import { ProjectId, ProviderDriverKind, ProviderInstanceId, + PROVIDER_SEND_TURN_MAX_ATTACHMENTS, ThreadId, type ModelSelection, type ProviderOptionSelection, @@ -65,7 +66,9 @@ import { markPromotedDraftThreadByRef, markPromotedDraftThreads, markPromotedDraftThreadsByRef, + type ComposerFileAttachment, type ComposerImageAttachment, + composerFileNeedsReattach, useComposerDraftStore, DraftId, } from "./composerDraftStore"; @@ -104,6 +107,18 @@ function makeImage(input: { }; } +function makeFile(id: string): ComposerFileAttachment { + const file = new File(["report"], "report.pdf", { type: "application/pdf" }); + return { + type: "file", + id, + name: file.name, + mimeType: file.type, + sizeBytes: file.size, + file, + }; +} + function makeTerminalContext(input: { id: string; text?: string; @@ -289,59 +304,335 @@ describe("composerDraftStore clearComposerContent", () => { }); }); -describe("composerDraftStore moveComposerPromptAndImages", () => { - const sourceDraftId = DraftId.make("draft-move-source"); - const destinationDraftId = DraftId.make("draft-move-destination"); - let originalRevokeObjectUrl: typeof URL.revokeObjectURL; - let revokeSpy: ReturnType void>>; +describe("composerDraftStore file attachments", () => { + const threadId = ThreadId.make("thread-files"); + const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); beforeEach(() => { resetComposerDraftStore(); - originalRevokeObjectUrl = URL.revokeObjectURL; - revokeSpy = vi.fn(); - URL.revokeObjectURL = revokeSpy; }); - afterEach(() => { - URL.revokeObjectURL = originalRevokeObjectUrl; + it("persists uploaded file references without including file contents", () => { + const store = useComposerDraftStore.getState(); + store.addFiles(threadRef, [makeFile("file-1")]); + store.setFileUpload(threadRef, "file-1", TEST_ENVIRONMENT_ID, "pending-report-pdf"); + + const persistApi = useComposerDraftStore.persist as unknown as { + getOptions: () => { + partialize: (state: ReturnType) => unknown; + merge: ( + persistedState: unknown, + currentState: ReturnType, + ) => ReturnType; + }; + }; + const options = persistApi.getOptions(); + const persisted = options.partialize(useComposerDraftStore.getState()) as { + draftsByThreadKey: Record> }>; + }; + + expect(persisted.draftsByThreadKey[threadKeyFor(threadId, TEST_ENVIRONMENT_ID)]?.files).toEqual( + [ + { + id: "file-1", + name: "report.pdf", + mimeType: "application/pdf", + sizeBytes: 6, + attachmentId: "pending-report-pdf", + environmentId: TEST_ENVIRONMENT_ID, + }, + ], + ); + + const hydrated = options.merge(persisted, useComposerDraftStore.getState()); + expect(hydrated.draftsByThreadKey[threadKeyFor(threadId, TEST_ENVIRONMENT_ID)]?.files).toEqual([ + { + type: "file", + id: "file-1", + name: "report.pdf", + mimeType: "application/pdf", + sizeBytes: 6, + file: null, + uploadedAttachmentId: "pending-report-pdf", + uploadEnvironmentId: TEST_ENVIRONMENT_ID, + }, + ]); }); - it("moves prompt and images to the destination without revoking preview URLs", () => { + it("persists a pending file as a needs-reattach marker instead of dropping it", () => { const store = useComposerDraftStore.getState(); - store.setPrompt(sourceDraftId, "fix the login redirect"); - store.addImages(sourceDraftId, [makeImage({ id: "img-move", previewUrl: "blob:move" })]); + // No setFileUpload: the upload never finished, so there is no attachment + // id and the File handle cannot serialize. + store.addFiles(threadRef, [makeFile("file-pending")]); - store.moveComposerPromptAndImages(sourceDraftId, destinationDraftId); + const persistApi = useComposerDraftStore.persist as unknown as { + getOptions: () => { + partialize: (state: ReturnType) => unknown; + merge: ( + persistedState: unknown, + currentState: ReturnType, + ) => ReturnType; + }; + }; + const options = persistApi.getOptions(); + const persisted = options.partialize(useComposerDraftStore.getState()) as { + draftsByThreadKey: Record> }>; + }; - expect(draftByKey(sourceDraftId)).toBeUndefined(); - const destination = draftByKey(destinationDraftId); - expect(destination?.prompt).toBe("fix the login redirect"); - expect(destination?.images.map((image) => image.id)).toEqual(["img-move"]); - expect(revokeSpy).not.toHaveBeenCalled(); + expect(persisted.draftsByThreadKey[threadKeyFor(threadId, TEST_ENVIRONMENT_ID)]?.files).toEqual( + [ + { + id: "file-pending", + name: "report.pdf", + mimeType: "application/pdf", + sizeBytes: 6, + }, + ], + ); + + const hydrated = options.merge(persisted, useComposerDraftStore.getState()); + const hydratedFiles = + hydrated.draftsByThreadKey[threadKeyFor(threadId, TEST_ENVIRONMENT_ID)]?.files; + expect(hydratedFiles).toEqual([ + { + type: "file", + id: "file-pending", + name: "report.pdf", + mimeType: "application/pdf", + sizeBytes: 6, + file: null, + }, + ]); + expect(hydratedFiles?.every(composerFileNeedsReattach)).toBe(true); }); - it("keeps session-bound contexts on the source and strips their placeholders from the moved prompt", () => { - const sourceThreadId = ThreadId.make("thread-move-source"); - const sourceThreadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, sourceThreadId); + it("marks only the matching byte-less upload as missing", () => { const store = useComposerDraftStore.getState(); - store.addTerminalContext(sourceThreadRef, makeTerminalContext({ id: "ctx-stay" })); - store.setPrompt(sourceThreadRef, `${INLINE_TERMINAL_CONTEXT_PLACEHOLDER} explain this error`); + const hydrated: ComposerFileAttachment = { + ...makeFile("file-hydrated"), + file: null, + uploadedAttachmentId: "pending-old", + uploadEnvironmentId: TEST_ENVIRONMENT_ID, + }; + const local: ComposerFileAttachment = { + ...makeFile("file-local"), + name: "local.txt", + mimeType: "text/plain", + }; + store.addFiles(threadRef, [hydrated, local]); + store.setFileUpload(threadRef, hydrated.id, TEST_ENVIRONMENT_ID, "pending-new"); + store.setFileUpload(threadRef, local.id, TEST_ENVIRONMENT_ID, "pending-local"); - store.moveComposerPromptAndImages(sourceThreadRef, destinationDraftId); + expect( + store.markFileUploadMissing(threadRef, hydrated.id, OTHER_TEST_ENVIRONMENT_ID, "pending-new"), + ).toBe(false); + expect( + store.markFileUploadMissing(threadRef, hydrated.id, TEST_ENVIRONMENT_ID, "pending-old"), + ).toBe(false); + expect( + store.markFileUploadMissing(threadRef, local.id, TEST_ENVIRONMENT_ID, "pending-local"), + ).toBe(false); - const source = draftFor(sourceThreadId, TEST_ENVIRONMENT_ID); - expect(source?.terminalContexts.map((context) => context.id)).toEqual(["ctx-stay"]); - expect(source?.prompt).toBe(INLINE_TERMINAL_CONTEXT_PLACEHOLDER); - expect(draftByKey(destinationDraftId)?.prompt).toBe(" explain this error"); + expect(store.getComposerDraft(threadRef)?.files).toMatchObject([ + { + id: hydrated.id, + uploadedAttachmentId: "pending-new", + uploadEnvironmentId: TEST_ENVIRONMENT_ID, + }, + { + id: local.id, + file: local.file, + uploadedAttachmentId: "pending-local", + uploadEnvironmentId: TEST_ENVIRONMENT_ID, + }, + ]); + + expect( + store.markFileUploadMissing(threadRef, hydrated.id, TEST_ENVIRONMENT_ID, "pending-new"), + ).toBe(true); + const marker = store.getComposerDraft(threadRef)?.files[0]; + expect(marker && composerFileNeedsReattach(marker)).toBe(true); + expect(marker?.uploadedAttachmentId).toBeUndefined(); + expect(marker?.uploadEnvironmentId).toBeUndefined(); }); - it("is a no-op when source and destination are the same target", () => { + it("removes generic files when the composer is cleared", () => { const store = useComposerDraftStore.getState(); - store.setPrompt(sourceDraftId, "keep me"); + store.addFiles(threadRef, [makeFile("file-clear")]); + + store.clearComposerContent(threadRef); + + expect(store.getComposerDraft(threadRef)).toBeNull(); + }); - store.moveComposerPromptAndImages(sourceDraftId, sourceDraftId); + it("removes generic files when a prompt is moved into the stash", () => { + const store = useComposerDraftStore.getState(); + store.setPrompt(threadRef, "Review the report"); + store.addFiles(threadRef, [makeFile("file-stash")]); + + store.clearComposerPromptAndImages(threadRef); + + expect(store.getComposerDraft(threadRef)).toBeNull(); + }); + + it("enforces the combined file and image limit across separate updates", () => { + const store = useComposerDraftStore.getState(); + const images = Array.from({ length: PROVIDER_SEND_TURN_MAX_ATTACHMENTS - 1 }, (_, index) => + makeImage({ + id: `image-${index}`, + name: `image-${index}.png`, + previewUrl: `blob:image-${index}`, + }), + ); + store.addImages(threadRef, images); + store.addFiles(threadRef, [ + makeFile("file-accepted"), + { ...makeFile("file-overflow"), name: "other.pdf" }, + ]); + store.addImages(threadRef, [ + makeImage({ id: "image-overflow", name: "overflow.png", previewUrl: "blob:overflow" }), + ]); + + const draft = store.getComposerDraft(threadRef); + expect(draft?.images).toHaveLength(PROVIDER_SEND_TURN_MAX_ATTACHMENTS - 1); + expect(draft?.files.map((file) => file.id)).toEqual(["file-accepted"]); + }); + + it("replaces a needs-reattach marker when the same file is picked again", () => { + const store = useComposerDraftStore.getState(); + // A hydrated marker: same metadata as the original pick, no bytes and no + // server-side upload. + const marker: ComposerFileAttachment = { ...makeFile("file-marker"), file: null }; + store.addFiles(threadRef, [marker]); + expect(store.getComposerDraft(threadRef)?.files.every(composerFileNeedsReattach)).toBe(true); + + // Following the "Attach again" instruction produces a fresh id with the + // exact metadata the dedup key hashes. + const repicked = makeFile("file-repicked"); + store.addFiles(threadRef, [repicked]); + + const files = store.getComposerDraft(threadRef)?.files; + expect(files?.map((file) => file.id)).toEqual(["file-repicked"]); + expect(files?.[0]?.file).not.toBeNull(); + expect(files?.some(composerFileNeedsReattach)).toBe(false); + }); + + it("replaces a legacy video marker after its MIME type is normalized", () => { + const store = useComposerDraftStore.getState(); + const marker: ComposerFileAttachment = { + type: "file", + id: "file-marker", + name: "clip.mkv", + mimeType: "application/octet-stream", + sizeBytes: 6, + file: null, + }; + store.addFiles(threadRef, [marker]); + + const file = new File(["report"], marker.name, { type: "video/x-matroska" }); + const repicked: ComposerFileAttachment = { + type: "file", + id: "file-repicked", + name: file.name, + mimeType: file.type, + sizeBytes: file.size, + file, + }; + store.addFiles(threadRef, [repicked, { ...repicked, id: "file-repicked-duplicate" }]); - expect(draftByKey(sourceDraftId)?.prompt).toBe("keep me"); + const files = store.getComposerDraft(threadRef)?.files; + expect(files?.map((entry) => entry.id)).toEqual(["file-repicked"]); + expect(files?.some(composerFileNeedsReattach)).toBe(false); + }); + + it("replaces a needs-reattach marker with a stash-restored uploaded file", () => { + const store = useComposerDraftStore.getState(); + const marker: ComposerFileAttachment = { ...makeFile("file-marker"), file: null }; + store.addFiles(threadRef, [marker]); + expect(store.getComposerDraft(threadRef)?.files.every(composerFileNeedsReattach)).toBe(true); + + // A stash restore carries a finished server-side upload instead of bytes. + // Matching metadata must replace the marker, not be dropped as a + // duplicate: the marker cannot send, and the restored ids are the only + // valid copy. + const restored: ComposerFileAttachment = { + ...makeFile("file-restored"), + file: null, + uploadedAttachmentId: "pending-stash-pdf", + uploadEnvironmentId: TEST_ENVIRONMENT_ID, + }; + store.addFiles(threadRef, [restored]); + + const files = store.getComposerDraft(threadRef)?.files; + expect(files?.map((file) => file.id)).toEqual(["file-restored"]); + expect(files?.[0]?.uploadedAttachmentId).toBe("pending-stash-pdf"); + expect(files?.[0]?.uploadEnvironmentId).toBe(TEST_ENVIRONMENT_ID); + expect(files?.some(composerFileNeedsReattach)).toBe(false); + }); + + it("still dedupes a re-pick against a file that does not need reattaching", () => { + const store = useComposerDraftStore.getState(); + store.addFiles(threadRef, [makeFile("file-original")]); + + store.addFiles(threadRef, [makeFile("file-duplicate")]); + + expect(store.getComposerDraft(threadRef)?.files.map((file) => file.id)).toEqual([ + "file-original", + ]); + }); + + it("keeps same-name videos with different MIME types", () => { + const store = useComposerDraftStore.getState(); + const mp4 = new File(["report"], "clip", { type: "video/mp4" }); + const webm = new File(["report"], "clip", { type: "video/webm" }); + + store.addFiles(threadRef, [ + { + type: "file", + id: "video-mp4", + name: mp4.name, + mimeType: mp4.type, + sizeBytes: mp4.size, + file: mp4, + }, + { + type: "file", + id: "video-webm", + name: webm.name, + mimeType: webm.type, + sizeBytes: webm.size, + file: webm, + }, + ]); + + expect(store.getComposerDraft(threadRef)?.files.map((file) => file.id)).toEqual([ + "video-mp4", + "video-webm", + ]); + }); + + it("keeps the remaining file slot available after a duplicate is skipped", () => { + const store = useComposerDraftStore.getState(); + store.addImages( + threadRef, + Array.from({ length: PROVIDER_SEND_TURN_MAX_ATTACHMENTS - 2 }, (_, index) => + makeImage({ + id: `image-${index}`, + name: `image-${index}.png`, + previewUrl: `blob:image-${index}`, + }), + ), + ); + store.addFiles(threadRef, [makeFile("file-original")]); + store.addFiles(threadRef, [ + makeFile("file-duplicate"), + { ...makeFile("file-unique"), name: "unique.pdf" }, + ]); + + expect(store.getComposerDraft(threadRef)?.files.map((file) => file.id)).toEqual([ + "file-original", + "file-unique", + ]); }); }); @@ -823,6 +1114,18 @@ describe("composerDraftStore project draft thread mapping", () => { }); }); + it("removes a draft's previous project mapping when retargeted in place", () => { + const store = useComposerDraftStore.getState(); + store.setProjectDraftThreadId(projectRef, draftId, { threadId }); + store.setPrompt(draftId, "keep this prompt"); + + store.setProjectDraftThreadId(otherProjectRef, draftId, { threadId }); + + expect(store.getDraftThreadByProjectRef(projectRef)).toBeNull(); + expect(store.getDraftThreadByProjectRef(otherProjectRef)?.draftId).toBe(draftId); + expect(store.getComposerDraft(draftId)?.prompt).toBe("keep this prompt"); + }); + it("rotates a failed bootstrap thread id without losing its draft", () => { const store = useComposerDraftStore.getState(); const retryThreadId = ThreadId.make("thread-retry"); @@ -1252,6 +1555,30 @@ describe("composerDraftStore project draft thread mapping", () => { }); }); + it("clears stale upload metadata when retargeting a draft to another environment", () => { + const store = useComposerDraftStore.getState(); + const hydratedFile: ComposerFileAttachment = { + ...makeFile("file-cross-environment"), + file: null, + uploadedAttachmentId: "local-environment-upload", + uploadEnvironmentId: TEST_ENVIRONMENT_ID, + }; + + store.setProjectDraftThreadId(projectRef, draftId, { threadId }); + store.addFiles(draftId, [hydratedFile]); + + store.setProjectDraftThreadId(remoteProjectRef, draftId, { threadId }); + + const file = store.getComposerDraft(draftId)?.files[0]; + expect(file).toMatchObject({ + id: hydratedFile.id, + file: null, + }); + expect(file?.uploadedAttachmentId).toBeUndefined(); + expect(file?.uploadEnvironmentId).toBeUndefined(); + expect(file && composerFileNeedsReattach(file)).toBe(true); + }); + it("clears branch and worktree but keeps env mode when changing a draft thread project ref", () => { const store = useComposerDraftStore.getState(); store.setProjectDraftThreadId(projectRef, draftId, { @@ -1314,6 +1641,47 @@ describe("composerDraftStore modelSelection", () => { ).toEqual(modelSelection(CODEX_DRIVER, "gpt-5.4")); }); + it("marks picker writes explicit and seeding writes non-explicit", () => { + const store = useComposerDraftStore.getState(); + store.setModelSelection(threadRef, modelSelection(CODEX_DRIVER, "gpt-5.4")); + expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionExplicit).toBeUndefined(); + + store.setModelSelection(threadRef, modelSelection(CODEX_DRIVER, "gpt-5.4"), { + explicit: true, + }); + expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionExplicit).toBe(true); + + // Last writer defines intent: a later seed clears the marker. + store.setModelSelection(threadRef, modelSelection(CODEX_DRIVER, "gpt-5.4"), { + replaceOptions: true, + }); + expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionExplicit).toBeUndefined(); + }); + + it("persists the explicit marker through storage round-trips", async () => { + vi.useFakeTimers(); + try { + useComposerDraftStore + .getState() + .setModelSelection(threadRef, modelSelection(CODEX_DRIVER, "gpt-5.4"), { + explicit: true, + }); + // Land the debounced persist write. + await vi.advanceTimersByTimeAsync(300); + + // Hydrate from the same storage the store persists into and verify the + // marker survives the partialize → decode → merge path. + resetComposerDraftStore(); + await useComposerDraftStore.persist.rehydrate(); + expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionExplicit).toBe(true); + expect( + draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider[CODEX_INSTANCE], + ).toEqual(modelSelection(CODEX_DRIVER, "gpt-5.4")); + } finally { + vi.useRealTimers(); + } + }); + it("replaces only the targeted provider options on the current model selection", () => { const store = useComposerDraftStore.getState(); @@ -1356,6 +1724,23 @@ describe("composerDraftStore modelSelection", () => { ); }); + it("marks trait edits as explicit model intent", () => { + const store = useComposerDraftStore.getState(); + store.setModelSelection(threadRef, modelSelection(CODEX_DRIVER, "gpt-5.4")); + expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionExplicit).toBeUndefined(); + + store.setProviderModelOptions( + threadRef, + CODEX_DRIVER, + toSelections({ reasoningEffort: "xhigh" }), + ); + + expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionExplicit).toBe(true); + expect( + draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider[CODEX_INSTANCE], + ).toEqual(modelSelection(CODEX_DRIVER, "gpt-5.4", { reasoningEffort: "xhigh" })); + }); + it("keeps explicit default-state overrides on the selection", () => { const store = useComposerDraftStore.getState(); @@ -1706,6 +2091,290 @@ describe("composerDraftStore sticky composer settings", () => { activeProvider: "claudeAgent", }); }); + + it("replaces a non-explicit stale model and its options with sticky state", () => { + const store = useComposerDraftStore.getState(); + const draftId = DraftId.make("draft-stale-sticky-seed"); + + store.setModelSelection( + draftId, + modelSelection(CODEX_DRIVER, "stale-model", { reasoningEffort: "low" }), + ); + store.setStickyModelSelection( + modelSelection(CODEX_DRIVER, "sticky-model", { reasoningEffort: "xhigh" }), + ); + store.applyStickyState(draftId); + + expect(draftByKey(draftId)).toMatchObject({ + activeProvider: CODEX_INSTANCE, + modelSelectionByProvider: { + [CODEX_INSTANCE]: modelSelection(CODEX_DRIVER, "sticky-model", { + reasoningEffort: "xhigh", + }), + }, + }); + }); + + it("clears a non-explicit stale model when there is no sticky state", () => { + const store = useComposerDraftStore.getState(); + const draftId = DraftId.make("draft-stale-without-sticky"); + + store.setModelSelection(draftId, modelSelection(CODEX_DRIVER, "stale-model")); + store.applyStickyState(draftId); + + expect(draftByKey(draftId)).toBeUndefined(); + }); +}); + +describe("composerDraftStore model seed migration", () => { + const staleDraftId = DraftId.make("draft-legacy-stale-model"); + const explicitDraftId = DraftId.make("draft-legacy-explicit-model"); + const typedDraftId = DraftId.make("draft-legacy-typed-model"); + const staleThreadId = ThreadId.make("thread-legacy-stale-model"); + const explicitThreadId = ThreadId.make("thread-legacy-explicit-model"); + const typedThreadId = ThreadId.make("thread-legacy-typed-model"); + const serverThreadId = ThreadId.make("thread-server-model"); + const serverThreadKey = scopedThreadKey(scopeThreadRef(TEST_ENVIRONMENT_ID, serverThreadId)); + const projectId = ProjectId.make("project-model-migration"); + const logicalProjectKey = `${TEST_ENVIRONMENT_ID}:/tmp/project-model-migration`; + + const draftThread = (threadId: ThreadId) => ({ + threadId, + environmentId: TEST_ENVIRONMENT_ID, + projectId, + logicalProjectKey, + createdAt: "2026-08-01T00:00:00.000Z", + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + envMode: "local", + startFromOrigin: false, + promotedTo: null, + }); + + beforeEach(async () => { + resetComposerDraftStore(); + await useComposerDraftStore.persist.clearStorage(); + }); + + afterEach(async () => { + await useComposerDraftStore.persist.clearStorage(); + }); + + it.each([1, 2])( + "keeps the legacy sticky Codex selection when v%s storage omitted the provider", + async (version) => { + vi.useFakeTimers(); + try { + const stickySelection = modelSelection(CODEX_DRIVER, "gpt-5.6-terra", { + reasoningEffort: "xhigh", + }); + const storage = useComposerDraftStore.persist.getOptions().storage; + expect(storage).toBeDefined(); + storage?.setItem(COMPOSER_DRAFT_STORAGE_KEY, { + version, + state: { + draftsByThreadId: {}, + draftThreadsByThreadId: {}, + projectDraftThreadIdByProjectId: {}, + stickyModel: stickySelection.model, + stickyModelOptions: providerModelOptions({ + [CODEX_DRIVER]: { reasoningEffort: "xhigh" }, + }), + }, + } as never); + await vi.advanceTimersByTimeAsync(300); + + await useComposerDraftStore.persist.rehydrate(); + + expect(useComposerDraftStore.getState()).toMatchObject({ + stickyModelSelectionByProvider: { [CODEX_INSTANCE]: stickySelection }, + stickyActiveProvider: null, + }); + } finally { + vi.useRealTimers(); + } + }, + ); + + it("strips seeded models only from empty draft sessions when upgrading storage", async () => { + vi.useFakeTimers(); + try { + const staleSelection = modelSelection(CODEX_DRIVER, "gpt-5.4"); + const stickySelection = modelSelection(CODEX_DRIVER, "gpt-5.6-terra", { + reasoningEffort: "xhigh", + }); + const storage = useComposerDraftStore.persist.getOptions().storage; + expect(storage).toBeDefined(); + storage?.setItem(COMPOSER_DRAFT_STORAGE_KEY, { + version: 8, + state: { + draftsByThreadKey: { + [staleDraftId]: { + prompt: "", + attachments: [], + modelSelectionByProvider: { [CODEX_INSTANCE]: staleSelection }, + activeProvider: CODEX_INSTANCE, + runtimeMode: "approval-required", + }, + [typedDraftId]: { + prompt: "keep this prompt", + attachments: [], + modelSelectionByProvider: { [CODEX_INSTANCE]: staleSelection }, + activeProvider: CODEX_INSTANCE, + }, + [explicitDraftId]: { + prompt: "", + attachments: [], + modelSelectionByProvider: { [CODEX_INSTANCE]: staleSelection }, + activeProvider: CODEX_INSTANCE, + modelSelectionExplicit: true, + }, + [serverThreadKey]: { + prompt: "", + attachments: [], + modelSelectionByProvider: { [CODEX_INSTANCE]: staleSelection }, + activeProvider: CODEX_INSTANCE, + }, + }, + draftThreadsByThreadKey: { + [staleDraftId]: draftThread(staleThreadId), + [explicitDraftId]: draftThread(explicitThreadId), + [typedDraftId]: draftThread(typedThreadId), + }, + logicalProjectDraftThreadKeyByLogicalProjectKey: { + [logicalProjectKey]: staleDraftId, + }, + stickyModelSelectionByProvider: { [CODEX_INSTANCE]: stickySelection }, + stickyActiveProvider: CODEX_INSTANCE, + }, + } as never); + await vi.advanceTimersByTimeAsync(300); + + await useComposerDraftStore.persist.rehydrate(); + + expect(draftByKey(staleDraftId)).toMatchObject({ + modelSelectionByProvider: {}, + activeProvider: null, + runtimeMode: "approval-required", + }); + expect(draftByKey(typedDraftId)).toMatchObject({ + prompt: "keep this prompt", + modelSelectionByProvider: { [CODEX_INSTANCE]: staleSelection }, + activeProvider: CODEX_INSTANCE, + }); + expect(draftByKey(explicitDraftId)).toMatchObject({ + modelSelectionByProvider: { [CODEX_INSTANCE]: staleSelection }, + activeProvider: CODEX_INSTANCE, + modelSelectionExplicit: true, + }); + expect(draftByKey(serverThreadKey)).toMatchObject({ + modelSelectionByProvider: { [CODEX_INSTANCE]: staleSelection }, + activeProvider: CODEX_INSTANCE, + }); + expect(useComposerDraftStore.getState().draftThreadsByThreadKey[staleDraftId]).toMatchObject({ + environmentId: TEST_ENVIRONMENT_ID, + projectId, + logicalProjectKey, + }); + expect(useComposerDraftStore.getState()).toMatchObject({ + stickyModelSelectionByProvider: { [CODEX_INSTANCE]: stickySelection }, + stickyActiveProvider: CODEX_INSTANCE, + }); + } finally { + vi.useRealTimers(); + } + }); + + it("keeps v8 file-only draft sessions and their seeded models", async () => { + vi.useFakeTimers(); + try { + const uploadedDraftId = DraftId.make("draft-legacy-uploaded-file"); + const markerDraftId = DraftId.make("draft-legacy-file-marker"); + const uploadedThreadId = ThreadId.make("thread-legacy-uploaded-file"); + const markerThreadId = ThreadId.make("thread-legacy-file-marker"); + const staleSelection = modelSelection(CODEX_DRIVER, "gpt-5.4"); + const storage = useComposerDraftStore.persist.getOptions().storage; + expect(storage).toBeDefined(); + storage?.setItem(COMPOSER_DRAFT_STORAGE_KEY, { + version: 8, + state: { + draftsByThreadKey: { + [uploadedDraftId]: { + prompt: "", + attachments: [], + files: [ + { + id: "file-uploaded", + name: "uploaded-report.pdf", + mimeType: "application/pdf", + sizeBytes: 128, + attachmentId: "attachment-uploaded", + environmentId: TEST_ENVIRONMENT_ID, + }, + ], + modelSelectionByProvider: { [CODEX_INSTANCE]: staleSelection }, + activeProvider: CODEX_INSTANCE, + }, + [markerDraftId]: { + prompt: "", + attachments: [], + files: [ + { + id: "file-needs-reattach", + name: "local-notes.txt", + mimeType: "text/plain", + sizeBytes: 64, + }, + ], + modelSelectionByProvider: { [CODEX_INSTANCE]: staleSelection }, + activeProvider: CODEX_INSTANCE, + runtimeMode: "approval-required", + }, + }, + draftThreadsByThreadKey: { + [uploadedDraftId]: draftThread(uploadedThreadId), + [markerDraftId]: draftThread(markerThreadId), + }, + logicalProjectDraftThreadKeyByLogicalProjectKey: {}, + stickyModelSelectionByProvider: {}, + stickyActiveProvider: null, + }, + } as never); + await vi.advanceTimersByTimeAsync(300); + + await useComposerDraftStore.persist.rehydrate(); + + expect(draftByKey(uploadedDraftId)).toMatchObject({ + files: [ + { + id: "file-uploaded", + name: "uploaded-report.pdf", + uploadedAttachmentId: "attachment-uploaded", + uploadEnvironmentId: TEST_ENVIRONMENT_ID, + }, + ], + modelSelectionByProvider: { [CODEX_INSTANCE]: staleSelection }, + activeProvider: CODEX_INSTANCE, + }); + expect(draftByKey(markerDraftId)).toMatchObject({ + files: [ + { + id: "file-needs-reattach", + name: "local-notes.txt", + file: null, + }, + ], + modelSelectionByProvider: { [CODEX_INSTANCE]: staleSelection }, + activeProvider: CODEX_INSTANCE, + runtimeMode: "approval-required", + }); + expect(draftByKey(markerDraftId)?.files.every(composerFileNeedsReattach)).toBe(true); + } finally { + vi.useRealTimers(); + } + }); }); describe("composerDraftStore provider-scoped option updates", () => { diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index f20385ee04f4..fe1dab199763 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -2,13 +2,14 @@ import { DEFAULT_MODEL, DEFAULT_MODEL_BY_PROVIDER, defaultInstanceIdForDriver, - type EnvironmentId, + EnvironmentId, ModelSelection, ProjectId, ProviderInstanceId, ProviderInteractionMode, ProviderDriverKind, ProviderOptionSelection, + PROVIDER_SEND_TURN_MAX_ATTACHMENTS, PreviewAnnotationPayloadSchema, type PreviewAnnotationPayload, RuntimeMode, @@ -33,12 +34,17 @@ import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model" import { useMemo } from "react"; import { getLocalStorageItem } from "./hooks/useLocalStorage"; import { resolveAppModelSelection, resolveAppModelSelectionForInstance } from "./modelSelection"; -import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type ChatImageAttachment } from "./types"; +import { + DEFAULT_INTERACTION_MODE, + DEFAULT_RUNTIME_MODE, + type ChatFileAttachment, + type ChatImageAttachment, + videoMimeType, +} from "./types"; import { type TerminalContextDraft, ensureInlineTerminalContextPlaceholders, normalizeTerminalContextText, - stripInlineTerminalContextPlaceholders, } from "./lib/terminalContext"; import { type ElementContextDraft, @@ -58,7 +64,7 @@ const isProviderDriverKind = Schema.is(ProviderDriverKind); const isReviewCommentContext = Schema.is(ReviewCommentContextSchema); export const COMPOSER_DRAFT_STORAGE_KEY = "t3code:composer-drafts:v1"; -const COMPOSER_DRAFT_STORAGE_VERSION = 8; +const COMPOSER_DRAFT_STORAGE_VERSION = 9; const DraftThreadEnvModeSchema = Schema.Literals(["local", "worktree"]); export type DraftThreadEnvMode = typeof DraftThreadEnvModeSchema.Type; @@ -93,6 +99,72 @@ export interface ComposerImageAttachment extends Omit { + file: File | null; + uploadedAttachmentId?: string; + uploadEnvironmentId?: EnvironmentId; +} + +/** + * A hydrated draft file whose upload never finished before the reload has + * neither bytes (`file` is only ever null after hydration) nor a server-side + * upload. The composer renders it as a needs-reattach row: the user must + * attach the file again or remove it before sending. + */ +export function composerFileNeedsReattach(file: ComposerFileAttachment): boolean { + return file.file === null && file.uploadedAttachmentId === undefined; +} + +function clearStaleFileUploadMetadata( + draft: ComposerThreadDraftState, + environmentId: EnvironmentId, +): ComposerThreadDraftState { + let changed = false; + const files = draft.files.map((file) => { + if ( + (file.uploadedAttachmentId === undefined && file.uploadEnvironmentId === undefined) || + file.uploadEnvironmentId === environmentId + ) { + return file; + } + + changed = true; + const nextFile = { ...file }; + delete nextFile.uploadedAttachmentId; + delete nextFile.uploadEnvironmentId; + return nextFile; + }); + + return changed ? { ...draft, files } : draft; +} + +export const PersistedComposerFileAttachment = Schema.Struct({ + id: Schema.String, + name: Schema.String, + mimeType: Schema.String, + sizeBytes: Schema.Number, + attachmentId: Schema.String, + environmentId: EnvironmentId, +}); +export type PersistedComposerFileAttachment = typeof PersistedComposerFileAttachment.Type; + +/** + * Draft-persisted file. Unlike a stash entry (which requires a finished + * upload), a draft may hold a file whose upload never completed. Its `File` + * handle cannot serialize, so it persists as a metadata-only marker (no + * `attachmentId`) and hydrates as a needs-reattach row instead of vanishing. + */ +export const PersistedComposerDraftFileAttachment = Schema.Struct({ + id: Schema.String, + name: Schema.String, + mimeType: Schema.String, + sizeBytes: Schema.Number, + attachmentId: Schema.optionalKey(Schema.String), + environmentId: Schema.optionalKey(EnvironmentId), +}); +export type PersistedComposerDraftFileAttachment = typeof PersistedComposerDraftFileAttachment.Type; +const isPersistedComposerDraftFileAttachment = Schema.is(PersistedComposerDraftFileAttachment); + const PersistedTerminalContextDraft = Schema.Struct({ id: Schema.String, threadId: ThreadId, @@ -129,6 +201,7 @@ type PersistedElementContextDraft = typeof PersistedElementContextDraft.Type; const PersistedComposerThreadDraftState = Schema.Struct({ prompt: Schema.String, attachments: Schema.Array(PersistedComposerImageAttachment), + files: Schema.optionalKey(Schema.Array(PersistedComposerDraftFileAttachment)), terminalContexts: Schema.optionalKey(Schema.Array(PersistedTerminalContextDraft)), elementContexts: Schema.optionalKey(Schema.Array(PersistedElementContextDraft)), previewAnnotations: Schema.optionalKey(Schema.Array(PreviewAnnotationPayloadSchema)), @@ -146,6 +219,10 @@ const PersistedComposerThreadDraftState = Schema.Struct({ // an entry already encodes "no selection for this instance". modelSelectionByProvider: Schema.optionalKey(Schema.Record(ProviderInstanceId, ModelSelection)), activeProvider: Schema.optionalKey(Schema.NullOr(ProviderInstanceId)), + // True only when a human picked this selection in the composer. Seeded + // selections (project default / sticky) leave it unset so later seeds can + // replace them; legacy entries predate the flag and read as seeded too. + modelSelectionExplicit: Schema.optionalKey(Schema.Boolean), runtimeMode: Schema.optionalKey(RuntimeMode), interactionMode: Schema.optionalKey(ProviderInteractionMode), }); @@ -251,6 +328,7 @@ const PersistedComposerDraftStoreStorage = Schema.Struct({ export interface ComposerThreadDraftState { prompt: string; images: ComposerImageAttachment[]; + files: ComposerFileAttachment[]; nonPersistedImageIds: string[]; persistedAttachments: PersistedComposerImageAttachment[]; terminalContexts: TerminalContextDraft[]; @@ -274,6 +352,12 @@ export interface ComposerThreadDraftState { modelSelectionByProvider: Partial>; /** Routing key of the last picked instance (see `modelSelectionByProvider`). */ activeProvider: ProviderInstanceId | null; + /** + * True only when a human picked the active selection in the composer. + * Absent/false means seeded (project default / sticky), so later seeds + * may replace it. Legacy entries predate the flag and read as seeded. + */ + modelSelectionExplicit?: boolean; runtimeMode: RuntimeMode | null; interactionMode: ProviderInteractionMode | null; } @@ -295,6 +379,7 @@ export function composerDraftHasUserContent( return ( draft.prompt.trim().length > 0 || draft.images.length > 0 || + draft.files.length > 0 || draft.persistedAttachments.length > 0 || draft.terminalContexts.length > 0 || draft.elementContexts.length > 0 || @@ -341,7 +426,7 @@ interface ProjectDraftSession extends DraftSessionState { * Raw `ThreadId` is intentionally excluded so callers cannot drop environment * identity for real threads. */ -type ComposerThreadTarget = ScopedThreadRef | DraftId; +export type ComposerThreadTarget = ScopedThreadRef | DraftId; /** * Persisted store for composer content plus draft-session metadata. @@ -372,7 +457,11 @@ interface ComposerDraftStoreState { getDraftThread: (threadRef: ComposerThreadTarget) => DraftThreadState | null; listDraftThreadKeys: () => string[]; hasDraftThreadsInEnvironment: (environmentId: EnvironmentId) => boolean; - /** Creates or updates the draft session tracked for a logical project. */ + /** + * Creates or updates the draft session tracked for a logical project. + * Reassigning an existing draft removes its previous logical-project + * mapping so one session cannot resolve from two projects. + */ setLogicalProjectDraftThreadId: ( logicalProjectKey: string, projectRef: ScopedProjectRef, @@ -434,6 +523,7 @@ interface ComposerDraftStoreState { threadRef: ComposerThreadTarget, modelSelection: ModelSelection | null | undefined, opts?: { + explicit?: boolean; /** * Replace the stored entry outright instead of preserving its * existing options when the incoming selection has none. Used when @@ -473,6 +563,20 @@ interface ComposerDraftStoreState { addImage: (threadRef: ComposerThreadTarget, image: ComposerImageAttachment) => void; addImages: (threadRef: ComposerThreadTarget, images: ComposerImageAttachment[]) => void; removeImage: (threadRef: ComposerThreadTarget, imageId: string) => void; + addFiles: (threadRef: ComposerThreadTarget, files: ComposerFileAttachment[]) => void; + removeFile: (threadRef: ComposerThreadTarget, fileId: string) => void; + setFileUpload: ( + threadRef: ComposerThreadTarget, + fileId: string, + environmentId: EnvironmentId, + attachmentId: string, + ) => void; + markFileUploadMissing: ( + threadRef: ComposerThreadTarget, + fileId: string, + environmentId: EnvironmentId, + attachmentId: string, + ) => boolean; insertTerminalContext: ( threadRef: ComposerThreadTarget, prompt: string, @@ -523,21 +627,11 @@ interface ComposerDraftStoreState { ) => void; clearComposerContent: (threadRef: ComposerThreadTarget) => void; /** - * Clears only the prompt text and image attachments, preserving terminal / + * Clears the prompt text and attachments, preserving terminal / * element contexts, preview annotations, and review comments. Used by the - * prompt stash, which can only round-trip text + images: clearing the - * session-bound contexts would destroy state nothing can restore. + * prompt stash. Session-bound context stays in the source draft. */ clearComposerPromptAndImages: (threadRef: ComposerThreadTarget) => void; - /** - * Moves the prompt text and image attachments from one composer target to - * another. Used when a draft changes project: the new project gets its own - * draft session and the typed content follows it. Session-bound extras - * (terminal / element contexts, preview annotations, review comments) stay - * on the source — they reference sessions of the source thread that the - * destination cannot use. - */ - moveComposerPromptAndImages: (from: ComposerThreadTarget, to: ComposerThreadTarget) => void; } export interface EffectiveComposerModelState { @@ -604,6 +698,7 @@ const EMPTY_PERSISTED_DRAFT_STORE_STATE = Object.freeze( const EMPTY_THREAD_DRAFT = Object.freeze({ prompt: "", images: EMPTY_IMAGES, + files: EMPTY_FILES, nonPersistedImageIds: EMPTY_IDS, persistedAttachments: EMPTY_PERSISTED_ATTACHMENTS, terminalContexts: EMPTY_TERMINAL_CONTEXTS, @@ -648,6 +745,7 @@ export function createEmptyThreadDraft(): ComposerThreadDraftState { return { prompt: "", images: [], + files: [], nonPersistedImageIds: [], persistedAttachments: [], terminalContexts: [], @@ -667,6 +765,26 @@ function composerImageDedupKey(image: ComposerImageAttachment): string { return `${image.mimeType}\u0000${image.sizeBytes}\u0000${image.name}`; } +export function composerFileDedupKey( + file: Pick, +): string { + return `${file.mimeType}\u0000${file.sizeBytes}\u0000${file.name}`; +} + +export function composerFileMatchesReattachMarker( + marker: Pick, + file: Pick, +): boolean { + if (marker.name !== file.name || marker.sizeBytes !== file.sizeBytes) return false; + if (marker.mimeType === file.mimeType) return true; + const markerMimeType = marker.mimeType.toLowerCase(); + return ( + (markerMimeType === "" || markerMimeType === "application/octet-stream") && + videoMimeType(marker) !== null && + videoMimeType(file) !== null + ); +} + function terminalContextDedupKey(context: TerminalContextDraft): string { return `${context.terminalId}\u0000${context.lineStart}\u0000${context.lineEnd}`; } @@ -722,6 +840,7 @@ function shouldRemoveDraft(draft: ComposerThreadDraftState): boolean { return ( draft.prompt.length === 0 && draft.images.length === 0 && + draft.files.length === 0 && draft.persistedAttachments.length === 0 && draft.terminalContexts.length === 0 && draft.elementContexts.length === 0 && @@ -1014,6 +1133,10 @@ export function deriveEffectiveComposerModelState(input: { }): EffectiveComposerModelState { const baseModelCandidate = input.threadModelSelection?.model ?? input.projectModelSelection?.model ?? null; + const preserveThreadModel = + input.selectedInstanceId !== null && + input.selectedInstanceId !== undefined && + input.threadModelSelection?.instanceId === input.selectedInstanceId; const baseModel = (input.selectedInstanceId ? resolveAppModelSelectionForInstance( @@ -1021,6 +1144,7 @@ export function deriveEffectiveComposerModelState(input: { input.settings, input.providers, baseModelCandidate, + { preserveUnavailableSelection: preserveThreadModel }, ) : null) ?? resolveAppModelSelection( @@ -1050,6 +1174,7 @@ export function deriveEffectiveComposerModelState(input: { input.settings, input.providers, activeSelection.model, + { preserveUnavailableSelection: true }, ) ?? resolveAppModelSelection( input.selectedProvider, @@ -1246,7 +1371,7 @@ function logicalProjectDraftKey(logicalProjectKey: string): string { * Draft sessions are keyed by `DraftId`. Real threads are keyed by * `ScopedThreadRef` so environment identity is always preserved. */ -function composerTargetKey(target: ScopedThreadRef | DraftId): string { +export function composerTargetKey(target: ScopedThreadRef | DraftId): string { if (typeof target === "string") { return target.trim(); } @@ -1609,12 +1734,18 @@ function normalizePersistedDraftThreads( const parsedThreadRef = parseScopedThreadKey(threadKeyOrId); const threadKey = normalizeLegacyComposerStorageKey(threadKeyOrId); logicalProjectDraftThreadKeyByLogicalProjectKey[logicalProjectKey] = threadKey; + const existingDraftThread = draftThreadsByThreadKey[threadKey]; if (parsedThreadRef) { environmentIdByThreadId.set(parsedThreadRef.threadId, parsedThreadRef.environmentId); } + // Logical project keys may contain a workspace path after the + // environment prefix. When the persisted draft already names that + // logical key, its concrete project id remains authoritative. + if (existingDraftThread?.logicalProjectKey === logicalProjectKey) { + continue; + } if (!projectRef) { - const existingDraftThread = draftThreadsByThreadKey[threadKey]; - if (existingDraftThread && !existingDraftThread.logicalProjectKey) { + if (existingDraftThread) { draftThreadsByThreadKey[threadKey] = { ...existingDraftThread, logicalProjectKey, @@ -1622,7 +1753,7 @@ function normalizePersistedDraftThreads( } continue; } - if (!draftThreadsByThreadKey[threadKey]) { + if (!existingDraftThread) { draftThreadsByThreadKey[threadKey] = { threadId: parsedThreadRef?.threadId ?? (threadKey as ThreadId), environmentId: projectRef.environmentId, @@ -1638,12 +1769,12 @@ function normalizePersistedDraftThreads( promotedTo: null, }; } else if ( - draftThreadsByThreadKey[threadKey]?.projectId !== projectRef.projectId || - draftThreadsByThreadKey[threadKey]?.environmentId !== projectRef.environmentId + existingDraftThread.projectId !== projectRef.projectId || + existingDraftThread.environmentId !== projectRef.environmentId ) { draftThreadsByThreadKey[threadKey] = { - ...draftThreadsByThreadKey[threadKey]!, - threadId: draftThreadsByThreadKey[threadKey]!.threadId, + ...existingDraftThread, + threadId: existingDraftThread.threadId, environmentId: projectRef.environmentId, projectId: projectRef.projectId, logicalProjectKey, @@ -1694,6 +1825,9 @@ function normalizePersistedDraftsByThreadId( return normalized ? [normalized] : []; }) : []; + const files = Array.isArray(draftCandidate.files) + ? draftCandidate.files.filter(isPersistedComposerDraftFileAttachment) + : []; const terminalContexts = Array.isArray(draftCandidate.terminalContexts) ? draftCandidate.terminalContexts.flatMap((entry) => { const normalized = normalizePersistedTerminalContextDraft(entry); @@ -1724,6 +1858,7 @@ function normalizePersistedDraftsByThreadId( const legacyDraftCandidate = draftValue as LegacyPersistedComposerThreadDraftState; let modelSelectionByProvider: Partial> = {}; let activeProvider: ProviderInstanceId | null = null; + let modelSelectionExplicit: true | undefined = undefined; if ( draftCandidate.modelSelectionByProvider && @@ -1734,6 +1869,7 @@ function normalizePersistedDraftsByThreadId( Record >; activeProvider = normalizeProviderInstanceId(draftCandidate.activeProvider); + modelSelectionExplicit = draftCandidate.modelSelectionExplicit === true ? true : undefined; } else { // v2 or legacy format: migrate const normalizedModelOptions = @@ -1771,6 +1907,7 @@ function normalizePersistedDraftsByThreadId( if ( promptCandidate.length === 0 && attachments.length === 0 && + files.length === 0 && terminalContexts.length === 0 && elementContexts.length === 0 && reviewComments.length === 0 && @@ -1795,6 +1932,7 @@ function normalizePersistedDraftsByThreadId( nextDraftsByThreadKey[normalizedThreadKey] = { prompt, attachments, + ...(files.length > 0 ? { files } : {}), ...(terminalContexts.length > 0 ? { terminalContexts } : {}), ...(elementContexts.length > 0 ? { elementContexts } : {}), ...(reviewComments.length > 0 ? { reviewComments } : {}), @@ -1802,6 +1940,7 @@ function normalizePersistedDraftsByThreadId( ? { modelSelectionByProvider: compactModelSelectionByProvider(modelSelectionByProvider), activeProvider, + ...(modelSelectionExplicit ? { modelSelectionExplicit: true } : {}), } : {}), ...(runtimeMode ? { runtimeMode } : {}), @@ -1812,55 +1951,53 @@ function normalizePersistedDraftsByThreadId( return nextDraftsByThreadKey; } -function migratePersistedComposerDraftStoreState( - persistedState: unknown, -): PersistedComposerDraftStoreState { - if (!persistedState || typeof persistedState !== "object") { - return EMPTY_PERSISTED_DRAFT_STORE_STATE; - } - const candidate = persistedState as LegacyPersistedComposerDraftStoreState; - const rawDraftMap = candidate.draftsByThreadKey ?? candidate.draftsByThreadId; - const rawDraftThreadsByThreadId = - candidate.draftThreadsByThreadKey ?? candidate.draftThreadsByThreadId; - const rawProjectDraftThreadIdByProjectKey = - candidate.logicalProjectDraftThreadKeyByLogicalProjectKey ?? - candidate.projectDraftThreadKeyByProjectKey ?? - candidate.projectDraftThreadIdByProjectKey ?? - candidate.projectDraftThreadIdByProjectId; - - // Migrate sticky state from v2 (dual) to v3 (consolidated) - const stickyModelOptions = normalizeProviderModelOptions(candidate.stickyModelOptions) ?? {}; - const normalizedStickyModelSelection = normalizeModelSelection(candidate.stickyModelSelection, { - provider: candidate.stickyProvider ?? "codex", - model: candidate.stickyModel, - modelOptions: stickyModelOptions, - }); - const nextStickyModelOptions = legacyMergeModelSelectionIntoProviderModelOptions( - normalizedStickyModelSelection, - stickyModelOptions, - ); - const stickyModelSelection = legacySyncModelSelectionOptions( - normalizedStickyModelSelection, - nextStickyModelOptions, - ); - const stickyModelSelectionByProvider = legacyToModelSelectionByProvider( - stickyModelSelection, - nextStickyModelOptions, +function persistedComposerDraftHasUserContent(draft: PersistedComposerThreadDraftState): boolean { + return ( + draft.prompt.trim().length > 0 || + draft.attachments.length > 0 || + (draft.files?.length ?? 0) > 0 || + (draft.terminalContexts?.length ?? 0) > 0 || + (draft.elementContexts?.length ?? 0) > 0 || + (draft.previewAnnotations?.length ?? 0) > 0 || + (draft.reviewComments?.length ?? 0) > 0 ); - const stickyActiveProvider = normalizeProviderInstanceId(candidate.stickyProvider) ?? null; +} - const { draftThreadsByThreadKey, logicalProjectDraftThreadKeyByLogicalProjectKey } = - normalizePersistedDraftThreads(rawDraftThreadsByThreadId, rawProjectDraftThreadIdByProjectKey); - const draftsByThreadKey = normalizePersistedDraftsByThreadId( - rawDraftMap, - draftThreadsByThreadKey, +function stripLegacyModelSeedsFromEmptyDraftSessions( + draftsByThreadKey: PersistedComposerDraftStoreState["draftsByThreadKey"], + draftThreadsByThreadKey: PersistedComposerDraftStoreState["draftThreadsByThreadKey"], +): PersistedComposerDraftStoreState["draftsByThreadKey"] { + return Object.fromEntries( + Object.entries(draftsByThreadKey).flatMap(([threadKey, draft]) => { + if ( + draftThreadsByThreadKey[threadKey] === undefined || + draft.modelSelectionExplicit === true || + persistedComposerDraftHasUserContent(draft) + ) { + return [[threadKey, draft]]; + } + + const { + activeProvider: _activeProvider, + modelSelectionByProvider: _modelSelectionByProvider, + modelSelectionExplicit: _modelSelectionExplicit, + ...retained + } = draft; + return retained.runtimeMode || retained.interactionMode ? [[threadKey, retained]] : []; + }), ); +} + +function migratePersistedComposerDraftStoreState( + persistedState: unknown, +): PersistedComposerDraftStoreState { + const normalized = normalizeCurrentPersistedComposerDraftStoreState(persistedState); return { - draftsByThreadKey, - draftThreadsByThreadKey, - logicalProjectDraftThreadKeyByLogicalProjectKey, - stickyModelSelectionByProvider: compactModelSelectionByProvider(stickyModelSelectionByProvider), - stickyActiveProvider, + ...normalized, + draftsByThreadKey: stripLegacyModelSeedsFromEmptyDraftSessions( + normalized.draftsByThreadKey, + normalized.draftThreadsByThreadKey, + ), }; } @@ -1902,6 +2039,7 @@ function partializeComposerDraftStoreState( if ( draft.prompt.length === 0 && draft.persistedAttachments.length === 0 && + draft.files.length === 0 && draft.terminalContexts.length === 0 && draft.elementContexts.length === 0 && draft.previewAnnotations.length === 0 && @@ -1915,6 +2053,25 @@ function partializeComposerDraftStoreState( const persistedDraft: DeepMutable = { prompt: draft.prompt, attachments: draft.persistedAttachments, + ...(draft.files.length > 0 + ? { + // A file whose upload has not finished has no serializable bytes. + // It persists as a metadata-only marker so it can surface as a + // needs-reattach row after reload instead of silently vanishing. + files: draft.files.map((file) => ({ + id: file.id, + name: file.name, + mimeType: file.mimeType, + sizeBytes: file.sizeBytes, + ...(file.uploadedAttachmentId && file.uploadEnvironmentId + ? { + attachmentId: file.uploadedAttachmentId, + environmentId: file.uploadEnvironmentId, + } + : {}), + })), + } + : {}), ...(draft.terminalContexts.length > 0 ? { terminalContexts: draft.terminalContexts.map((context) => ({ @@ -1963,6 +2120,7 @@ function partializeComposerDraftStoreState( draft.modelSelectionByProvider, ), activeProvider: draft.activeProvider, + ...(draft.modelSelectionExplicit ? { modelSelectionExplicit: true } : {}), } : {}), ...(draft.runtimeMode ? { runtimeMode: draft.runtimeMode } : {}), @@ -2029,7 +2187,7 @@ function normalizeCurrentPersistedComposerDraftStoreState( const normalizedStickyModelSelection = normalizeModelSelection( normalizedPersistedState.stickyModelSelection, { - provider: normalizedPersistedState.stickyProvider, + provider: normalizedPersistedState.stickyProvider ?? "codex", model: normalizedPersistedState.stickyModel, modelOptions: stickyModelOptions, }, @@ -2195,6 +2353,21 @@ function toHydratedThreadDraft( return { prompt: persistedDraft.prompt, images: hydrateImagesFromPersisted(persistedDraft.attachments), + files: + persistedDraft.files?.map((file) => ({ + type: "file" as const, + id: file.id, + name: file.name, + mimeType: file.mimeType, + sizeBytes: file.sizeBytes, + file: null, + // A marker without an attachment id hydrates as needs-reattach: no + // bytes, no server-side upload, only the metadata to tell the user + // what to attach again. + ...(file.attachmentId !== undefined && file.environmentId !== undefined + ? { uploadedAttachmentId: file.attachmentId, uploadEnvironmentId: file.environmentId } + : {}), + })) ?? [], nonPersistedImageIds: [], persistedAttachments: [...persistedDraft.attachments], terminalContexts: @@ -2211,6 +2384,7 @@ function toHydratedThreadDraft( reviewComments: persistedDraft.reviewComments?.map((comment) => ({ ...comment })) ?? [], modelSelectionByProvider, activeProvider, + ...(persistedDraft.modelSelectionExplicit ? { modelSelectionExplicit: true } : {}), runtimeMode: persistedDraft.runtimeMode ?? null, interactionMode: persistedDraft.interactionMode ?? null, }; @@ -2360,18 +2534,53 @@ const composerDraftStore = create()( options, ); const hasSameLogicalMapping = previousThreadKeyForLogicalProject === draftId; - if (hasSameLogicalMapping && draftThreadsEqual(existingThread, nextDraftThread)) { + const hasNoStaleMappingsForDraft = Object.entries( + state.logicalProjectDraftThreadKeyByLogicalProjectKey, + ).every( + ([logicalKey, mappedDraftId]) => + mappedDraftId !== draftId || logicalKey === normalizedLogicalProjectKey, + ); + if ( + hasSameLogicalMapping && + hasNoStaleMappingsForDraft && + draftThreadsEqual(existingThread, nextDraftThread) + ) { return state; } - const nextLogicalProjectDraftThreadKeyByLogicalProjectKey: Record = { - ...state.logicalProjectDraftThreadKeyByLogicalProjectKey, - [normalizedLogicalProjectKey]: draftId, - }; + // A draft session belongs to one logical project at a time. When + // an open draft is retargeted in place, remove any old mapping + // for that same draft so the previous project cannot resolve it. + const nextLogicalProjectDraftThreadKeyByLogicalProjectKey: Record = + Object.fromEntries( + Object.entries(state.logicalProjectDraftThreadKeyByLogicalProjectKey).filter( + ([logicalKey, mappedDraftId]) => + mappedDraftId !== draftId || logicalKey === normalizedLogicalProjectKey, + ), + ); + nextLogicalProjectDraftThreadKeyByLogicalProjectKey[normalizedLogicalProjectKey] = + draftId; const nextDraftThreadsByThreadKey: Record = { ...state.draftThreadsByThreadKey, [draftId]: nextDraftThread, }; + const existingDraft = state.draftsByThreadKey[draftId]; let nextDraftsByThreadKey = state.draftsByThreadKey; + if ( + existingThread && + existingThread.environmentId !== projectRef.environmentId && + existingDraft !== undefined + ) { + const nextDraft = clearStaleFileUploadMetadata( + existingDraft, + projectRef.environmentId, + ); + if (nextDraft !== existingDraft) { + nextDraftsByThreadKey = { + ...state.draftsByThreadKey, + [draftId]: nextDraft, + }; + } + } const previousDraftThread = previousThreadKeyForLogicalProject === undefined ? undefined @@ -2395,7 +2604,7 @@ const composerDraftStore = create()( ) { delete nextDraftThreadsByThreadKey[previousThreadKeyForLogicalProject]; if (state.draftsByThreadKey[previousThreadKeyForLogicalProject] !== undefined) { - nextDraftsByThreadKey = { ...state.draftsByThreadKey }; + nextDraftsByThreadKey = { ...nextDraftsByThreadKey }; delete nextDraftsByThreadKey[previousThreadKeyForLogicalProject]; } } @@ -2628,33 +2837,19 @@ const composerDraftStore = create()( set((state) => { const stickyMap = state.stickyModelSelectionByProvider; const stickyActiveProvider = state.stickyActiveProvider; - if (Object.keys(stickyMap).length === 0 && stickyActiveProvider === null) { - return state; - } const existing = state.draftsByThreadKey[threadKey]; const base = existing ?? createEmptyThreadDraft(); - const nextMap = { ...base.modelSelectionByProvider }; - for (const [provider, selection] of Object.entries(stickyMap)) { - if (selection) { - // Iteration key comes from the instance-keyed sticky map, - // so coerce the string back to `ProviderInstanceId` for - // the typed lookup. - const instanceKey = provider as ProviderInstanceId; - const current = nextMap[instanceKey]; - nextMap[instanceKey] = { - ...selection, - model: current?.model ?? selection.model, - }; - } - } + const nextMap = compactModelSelectionByProvider(stickyMap); if ( Equal.equals(base.modelSelectionByProvider, nextMap) && - base.activeProvider === stickyActiveProvider + base.activeProvider === stickyActiveProvider && + base.modelSelectionExplicit === undefined ) { return state; } + const { modelSelectionExplicit: _modelSelectionExplicit, ...retained } = base; const nextDraft: ComposerThreadDraftState = { - ...base, + ...retained, modelSelectionByProvider: nextMap, activeProvider: stickyActiveProvider, }; @@ -2745,14 +2940,20 @@ const composerDraftStore = create()( const nextActiveProvider = normalized?.instanceId ?? base.activeProvider; if ( Equal.equals(base.modelSelectionByProvider, nextMap) && - base.activeProvider === nextActiveProvider + base.activeProvider === nextActiveProvider && + (base.modelSelectionExplicit ?? false) === (opts?.explicit === true) ) { return state; } + // Last writer defines intent: picker writes mark the selection + // explicit; seeding writes leave it unset so future seeds can + // replace it. + const { modelSelectionExplicit: _previousExplicit, ...restBase } = base; const nextDraft: ComposerThreadDraftState = { - ...base, + ...restBase, modelSelectionByProvider: nextMap, activeProvider: nextActiveProvider, + ...(opts?.explicit === true ? { modelSelectionExplicit: true as const } : {}), }; const nextDraftsByThreadKey = { ...state.draftsByThreadKey }; if (shouldRemoveDraft(nextDraft)) { @@ -2875,10 +3076,14 @@ const composerDraftStore = create()( return state; } + // Trait edits are user-driven intent: mark the selection explicit + // so later seeds cannot silently replace the chosen options. + const { modelSelectionExplicit: _previousExplicit, ...restBase } = base; const nextDraft: ComposerThreadDraftState = { - ...base, + ...restBase, ...(options?.instanceId ? { activeProvider: instanceKey } : {}), modelSelectionByProvider: nextMap, + modelSelectionExplicit: true, }; const nextDraftsByThreadKey = { ...state.draftsByThreadKey }; if (shouldRemoveDraft(nextDraft)) { @@ -2987,6 +3192,15 @@ const composerDraftStore = create()( } continue; } + if ( + existing.images.length + existing.files.length + dedupedIncoming.length >= + PROVIDER_SEND_TURN_MAX_ATTACHMENTS + ) { + if (!acceptedPreviewUrls.has(image.previewUrl)) { + revokeObjectPreviewUrl(image.previewUrl); + } + continue; + } dedupedIncoming.push(image); existingIds.add(image.id); existingDedupKeys.add(dedupKey); @@ -3041,6 +3255,163 @@ const composerDraftStore = create()( return { draftsByThreadKey: nextDraftsByThreadKey }; }); }, + addFiles: (threadRef, files) => { + const threadKey = resolveComposerDraftKey(get(), threadRef) ?? ""; + if (threadKey.length === 0 || files.length === 0) { + return; + } + set((state) => { + const existing = state.draftsByThreadKey[threadKey] ?? createEmptyThreadDraft(); + const knownIds = new Set(existing.files.map((file) => file.id)); + const knownFiles = new Map( + existing.files.map((file) => [composerFileDedupKey(file), file]), + ); + const accepted: ComposerFileAttachment[] = []; + // Needs-reattach markers replaced in place by a re-pick, keyed by + // the marker's id. + const replacements = new Map(); + for (const file of files) { + const key = composerFileDedupKey(file); + if (knownIds.has(file.id)) { + continue; + } + const duplicate = + knownFiles.get(key) ?? + existing.files.find( + (candidate) => + composerFileNeedsReattach(candidate) && + !replacements.has(candidate.id) && + composerFileMatchesReattachMarker(candidate, file), + ); + if (duplicate) { + // A needs-reattach marker is not a usable duplicate. Replace + // it so the upload restarts. + if (composerFileNeedsReattach(duplicate) && !replacements.has(duplicate.id)) { + replacements.set(duplicate.id, file); + knownIds.add(file.id); + knownFiles.set(key, file); + } + continue; + } + if ( + existing.images.length + existing.files.length + accepted.length >= + PROVIDER_SEND_TURN_MAX_ATTACHMENTS + ) { + break; + } + accepted.push(file); + knownIds.add(file.id); + knownFiles.set(key, file); + } + if (accepted.length === 0 && replacements.size === 0) { + return state; + } + const retained = existing.files.map((file) => replacements.get(file.id) ?? file); + return { + draftsByThreadKey: { + ...state.draftsByThreadKey, + [threadKey]: { ...existing, files: [...retained, ...accepted] }, + }, + }; + }); + }, + removeFile: (threadRef, fileId) => { + const threadKey = resolveComposerDraftKey(get(), threadRef) ?? ""; + if (threadKey.length === 0) { + return; + } + set((state) => { + const current = state.draftsByThreadKey[threadKey]; + if (!current?.files.some((file) => file.id === fileId)) { + return state; + } + const nextDraft = { + ...current, + files: current.files.filter((file) => file.id !== fileId), + } satisfies ComposerThreadDraftState; + const nextDraftsByThreadKey = { ...state.draftsByThreadKey }; + if (shouldRemoveDraft(nextDraft)) { + delete nextDraftsByThreadKey[threadKey]; + } else { + nextDraftsByThreadKey[threadKey] = nextDraft; + } + return { draftsByThreadKey: nextDraftsByThreadKey }; + }); + }, + setFileUpload: (threadRef, fileId, environmentId, attachmentId) => { + const threadKey = resolveComposerDraftKey(get(), threadRef) ?? ""; + if (threadKey.length === 0) { + return; + } + set((state) => { + const current = state.draftsByThreadKey[threadKey]; + const file = current?.files.find((entry) => entry.id === fileId); + if ( + !current || + !file || + (file.uploadEnvironmentId === environmentId && + file.uploadedAttachmentId === attachmentId) + ) { + return state; + } + return { + draftsByThreadKey: { + ...state.draftsByThreadKey, + [threadKey]: { + ...current, + files: current.files.map((entry) => + entry.id === fileId + ? { + ...entry, + uploadedAttachmentId: attachmentId, + uploadEnvironmentId: environmentId, + } + : entry, + ), + }, + }, + }; + }); + }, + markFileUploadMissing: (threadRef, fileId, environmentId, attachmentId) => { + const threadKey = resolveComposerDraftKey(get(), threadRef) ?? ""; + if (threadKey.length === 0) { + return false; + } + let markedMissing = false; + set((state) => { + const current = state.draftsByThreadKey[threadKey]; + const file = current?.files.find((entry) => entry.id === fileId); + if ( + !current || + !file || + file.file !== null || + file.uploadEnvironmentId !== environmentId || + file.uploadedAttachmentId !== attachmentId + ) { + return state; + } + markedMissing = true; + return { + draftsByThreadKey: { + ...state.draftsByThreadKey, + [threadKey]: { + ...current, + files: current.files.map((entry) => { + if (entry.id !== fileId) { + return entry; + } + const marker = { ...entry }; + delete marker.uploadedAttachmentId; + delete marker.uploadEnvironmentId; + return marker; + }), + }, + }, + }; + }); + return markedMissing; + }, insertTerminalContext: (threadRef, prompt, context, index) => { const threadKey = resolveComposerDraftKey(get(), threadRef); const threadId = resolveComposerThreadId(get(), threadRef); @@ -3441,6 +3812,7 @@ const composerDraftStore = create()( ...current, prompt: "", images: [], + files: [], nonPersistedImageIds: [], persistedAttachments: [], terminalContexts: [], @@ -3474,6 +3846,7 @@ const composerDraftStore = create()( ...current, prompt: ensureInlineTerminalContextPlaceholders("", current.terminalContexts.length), images: [], + files: [], nonPersistedImageIds: [], persistedAttachments: [], }; @@ -3486,62 +3859,6 @@ const composerDraftStore = create()( return { draftsByThreadKey: nextDraftsByThreadKey }; }); }, - moveComposerPromptAndImages: (from, to) => { - const fromKey = resolveComposerDraftKey(get(), from) ?? ""; - const toKey = resolveComposerDraftKey(get(), to) ?? ""; - if (fromKey.length === 0 || toKey.length === 0 || fromKey === toKey) { - return; - } - set((state) => { - const source = state.draftsByThreadKey[fromKey]; - if (!source) { - return state; - } - const destination = state.draftsByThreadKey[toKey] ?? createEmptyThreadDraft(); - // Inline placeholders reference the source's terminal contexts, - // which stay behind; re-anchor the moved prompt to whatever - // contexts the destination already holds. - const movedPrompt = ensureInlineTerminalContextPlaceholders( - stripInlineTerminalContextPlaceholders(source.prompt), - destination.terminalContexts.length, - ); - const nextDestination: ComposerThreadDraftState = { - ...destination, - prompt: movedPrompt, - images: [...destination.images, ...source.images], - nonPersistedImageIds: [ - ...destination.nonPersistedImageIds, - ...source.nonPersistedImageIds, - ], - persistedAttachments: [ - ...destination.persistedAttachments, - ...source.persistedAttachments, - ], - }; - // Same clearing shape as clearComposerPromptAndImages, but the - // preview URLs are NOT revoked: the images moved and their blobs - // are still referenced from the destination. - const nextSource: ComposerThreadDraftState = { - ...source, - prompt: ensureInlineTerminalContextPlaceholders("", source.terminalContexts.length), - images: [], - nonPersistedImageIds: [], - persistedAttachments: [], - }; - const nextDraftsByThreadKey = { ...state.draftsByThreadKey }; - if (shouldRemoveDraft(nextSource)) { - delete nextDraftsByThreadKey[fromKey]; - } else { - nextDraftsByThreadKey[fromKey] = nextSource; - } - if (shouldRemoveDraft(nextDestination)) { - delete nextDraftsByThreadKey[toKey]; - } else { - nextDraftsByThreadKey[toKey] = nextDestination; - } - return { draftsByThreadKey: nextDraftsByThreadKey }; - }); - }, }; }, { diff --git a/apps/web/src/connection/clientMetadata.test.ts b/apps/web/src/connection/clientMetadata.test.ts new file mode 100644 index 000000000000..fc6aaa2db434 --- /dev/null +++ b/apps/web/src/connection/clientMetadata.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + browserClientOs, + browserDeviceType, + browserFamily, + clientPresentationMetadata, +} from "./clientMetadata"; + +const desktopChrome = { + userAgent: + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/140.0.0.0 Safari/537.36", + platform: "Win32", + maxTouchPoints: 0, +}; + +describe("client telemetry metadata", () => { + it("distinguishes hosted web from server-served web", () => { + expect( + clientPresentationMetadata({ + appVersion: "1.2.3", + hosted: true, + identity: desktopChrome, + desktopBridge: undefined, + }), + ).toMatchObject({ + surface: "web", + webDeployment: "hosted", + deviceType: "desktop", + os: "Windows", + browser: "Chrome", + appVersion: "1.2.3", + }); + + expect( + clientPresentationMetadata({ + appVersion: "0.0.0", + hosted: false, + identity: desktopChrome, + desktopBridge: undefined, + }), + ).toMatchObject({ surface: "web", webDeployment: "server" }); + }); + + it("identifies phone and tablet browsers", () => { + const iphone = { + userAgent: + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) AppleWebKit/605.1.15 Version/18.6 Mobile/15E148 Safari/604.1", + platform: "iPhone", + maxTouchPoints: 5, + }; + const androidTablet = { + userAgent: + "Mozilla/5.0 (Linux; Android 15; Pixel Tablet) AppleWebKit/537.36 Chrome/140.0.0.0 Safari/537.36", + platform: "Linux armv8l", + maxTouchPoints: 5, + }; + const ipadosDesktopUa = { + userAgent: + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15) AppleWebKit/605.1.15 Version/18.0 Safari/605.1.15", + platform: "MacIntel", + maxTouchPoints: 5, + }; + + expect(browserDeviceType(iphone)).toBe("mobile"); + expect(browserClientOs(iphone)).toBe("iOS"); + expect(browserFamily(iphone.userAgent)).toBe("Safari"); + expect(browserDeviceType(androidTablet)).toBe("tablet"); + expect(browserClientOs(androidTablet)).toBe("Android"); + expect(browserDeviceType(ipadosDesktopUa)).toBe("tablet"); + expect(browserClientOs(ipadosDesktopUa)).toBe("iOS"); + }); + + it("uses Electron's client platform for desktop", () => { + expect( + clientPresentationMetadata({ + appVersion: "1.2.3", + hosted: false, + identity: desktopChrome, + desktopBridge: { getClientPlatform: () => "darwin" }, + }), + ).toEqual({ + label: "T3 Code Desktop", + deviceType: "desktop", + os: "macOS", + surface: "desktop", + appVersion: "1.2.3", + }); + }); +}); diff --git a/apps/web/src/connection/clientMetadata.ts b/apps/web/src/connection/clientMetadata.ts new file mode 100644 index 000000000000..8e1b75eb0a51 --- /dev/null +++ b/apps/web/src/connection/clientMetadata.ts @@ -0,0 +1,97 @@ +import type { + AuthClientMetadataDeviceType, + AuthClientPresentationMetadata, + ClientOs, + DesktopBridge, +} from "@t3tools/contracts"; + +interface BrowserIdentity { + readonly userAgent: string; + readonly platform: string; + readonly maxTouchPoints: number; +} + +function clientOsFromElectronPlatform(platform: string | undefined): ClientOs { + switch (platform) { + case "darwin": + return "macOS"; + case "win32": + return "Windows"; + case "linux": + return "Linux"; + default: + return platform ? "other" : "unknown"; + } +} + +function isIpadosDesktopUserAgent(identity: BrowserIdentity): boolean { + return ( + /macintosh/i.test(identity.userAgent) && + /mac/i.test(identity.platform) && + identity.maxTouchPoints > 1 + ); +} + +export function browserClientOs(identity: BrowserIdentity): ClientOs { + const userAgent = identity.userAgent; + if (userAgent.trim() === "") return "unknown"; + if (/iphone|ipad|ipod/i.test(userAgent) || isIpadosDesktopUserAgent(identity)) return "iOS"; + if (/android/i.test(userAgent)) return "Android"; + if (/cros/i.test(userAgent)) return "ChromeOS"; + if (/windows/i.test(userAgent)) return "Windows"; + if (/macintosh|mac os x/i.test(userAgent)) return "macOS"; + if (/linux|x11/i.test(userAgent)) return "Linux"; + return "other"; +} + +export function browserFamily(userAgent: string): string { + if (userAgent.trim() === "") return "unknown"; + if (/edg(?:e|a|ios)?\//i.test(userAgent)) return "Edge"; + if (/opr\/|opios\//i.test(userAgent)) return "Opera"; + if (/samsungbrowser\//i.test(userAgent)) return "Samsung Internet"; + if (/firefox\/|fxios\//i.test(userAgent)) return "Firefox"; + if (/chrome\/|crios\//i.test(userAgent)) return "Chrome"; + if (/safari\//i.test(userAgent)) return "Safari"; + return "other"; +} + +export function browserDeviceType(identity: BrowserIdentity): AuthClientMetadataDeviceType { + const userAgent = identity.userAgent; + if (userAgent.trim() === "") return "unknown"; + if ( + /ipad|tablet|kindle|silk/i.test(userAgent) || + (/android/i.test(userAgent) && !/mobile/i.test(userAgent)) || + isIpadosDesktopUserAgent(identity) + ) { + return "tablet"; + } + if (/iphone|ipod|android.+mobile|mobile/i.test(userAgent)) return "mobile"; + return "desktop"; +} + +export function clientPresentationMetadata(input: { + readonly appVersion: string; + readonly hosted: boolean; + readonly identity: BrowserIdentity; + readonly desktopBridge: Pick | undefined; +}): AuthClientPresentationMetadata { + if (input.desktopBridge !== undefined) { + return { + label: "T3 Code Desktop", + deviceType: "desktop", + os: clientOsFromElectronPlatform(input.desktopBridge.getClientPlatform?.()), + surface: "desktop", + ...(input.appVersion === "0.0.0" ? {} : { appVersion: input.appVersion }), + }; + } + + return { + label: "T3 Code Web", + deviceType: browserDeviceType(input.identity), + os: browserClientOs(input.identity), + surface: "web", + webDeployment: input.hosted ? "hosted" : "server", + browser: browserFamily(input.identity.userAgent), + ...(input.appVersion === "0.0.0" ? {} : { appVersion: input.appVersion }), + }; +} diff --git a/apps/web/src/connection/platform.ts b/apps/web/src/connection/platform.ts index daead0bc6308..ac9f77c53c2d 100644 --- a/apps/web/src/connection/platform.ts +++ b/apps/web/src/connection/platform.ts @@ -59,6 +59,7 @@ import { type DesktopSecondaryBootstrapsRead, } from "./desktopLocal"; import { connectionStorageLayer } from "./storage"; +import { clientPresentationMetadata } from "./clientMetadata"; let nextObservedRpcRequestId = 0; @@ -115,15 +116,16 @@ const wakeupsLayer = Wakeups.layer({ }); function clientMetadata() { - const desktop = window.desktopBridge !== undefined; - const platform = navigator.platform.trim(); - return { - label: desktop ? "T3 Code Desktop" : "T3 Code Web", - deviceType: "desktop" as const, - ...(platform === "" ? {} : { os: platform }), - surface: desktop ? ("desktop" as const) : ("web" as const), - ...(APP_VERSION === "0.0.0" ? {} : { appVersion: APP_VERSION }), - }; + return clientPresentationMetadata({ + appVersion: APP_VERSION, + hosted: isHostedStaticApp(), + identity: { + userAgent: navigator.userAgent, + platform: navigator.platform, + maxTouchPoints: navigator.maxTouchPoints, + }, + desktopBridge: window.desktopBridge, + }); } function sshPreparationError(cause: unknown) { diff --git a/apps/web/src/connection/runtime.ts b/apps/web/src/connection/runtime.ts index b63d01999036..06c8bf0ccfed 100644 --- a/apps/web/src/connection/runtime.ts +++ b/apps/web/src/connection/runtime.ts @@ -30,7 +30,10 @@ type ConnectionLayerSource = | typeof backgroundActivityObserverLayer | typeof backgroundActivityReporterLayer; -const providedClientConnectionLayer = Layer.merge(Connection.layer, snapshotLoaderLayer).pipe( +const providedClientConnectionLayer = Layer.merge( + Connection.layerWithOptions({ environmentThemes: true }), + snapshotLoaderLayer, +).pipe( Layer.provideMerge( Layer.mergeAll( runtimeContextLayer, diff --git a/apps/web/src/contextMenuFallback.ts b/apps/web/src/contextMenuFallback.ts index ce8b8950a8b9..2c43641b2b56 100644 --- a/apps/web/src/contextMenuFallback.ts +++ b/apps/web/src/contextMenuFallback.ts @@ -96,6 +96,15 @@ const ICON_PATHS: Record void) | null = null; +export function isContextMenuOpen(): boolean { + return activeContextMenuDismiss !== null; +} + /** * Closes the currently open fallback context menu, resolving its show() with * null (the same result as dismissing by outside click or Escape). No-op when diff --git a/apps/web/src/desktopAppActivation.test.ts b/apps/web/src/desktopAppActivation.test.ts new file mode 100644 index 000000000000..e362391ed682 --- /dev/null +++ b/apps/web/src/desktopAppActivation.test.ts @@ -0,0 +1,107 @@ +import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { + handleDesktopAppActivationRequest, + type DesktopAppActivationDependencies, +} from "./desktopAppActivation"; + +const environmentId = EnvironmentId.make("primary"); +const existingProjectId = ProjectId.make("project-existing"); +const createdProjectId = ProjectId.make("project-created"); +const threadId = ThreadId.make("thread-1"); +const request = { + version: 1, + requestId: "request-1", + type: "open-workspace", + workspaceRoot: "/workspace/project", + platform: "linux", +} as const; + +function dependencies( + overrides: Partial = {}, +): DesktopAppActivationDependencies { + return { + getTarget: () => ({ environmentId, platform: "linux" }), + findProject: () => ({ + id: existingProjectId, + environmentId, + workspaceRoot: request.workspaceRoot, + }), + createProject: vi.fn(async () => createdProjectId), + waitForProject: vi.fn(async () => undefined), + openThread: vi.fn(async () => ({ threadId })), + ...overrides, + }; +} + +describe("desktop app activation", () => { + it("reuses an existing project and opens a new thread", async () => { + const deps = dependencies(); + + const response = await handleDesktopAppActivationRequest(request, deps); + + expect(deps.createProject).not.toHaveBeenCalled(); + expect(deps.openThread).toHaveBeenCalledWith({ environmentId, projectId: existingProjectId }); + expect(response).toEqual({ + version: 1, + requestId: request.requestId, + ok: true, + projectId: existingProjectId, + threadId, + }); + }); + + it("waits for a created project before it opens the thread", async () => { + const order: string[] = []; + const deps = dependencies({ + findProject: () => null, + createProject: vi.fn(async () => { + order.push("create"); + return createdProjectId; + }), + waitForProject: vi.fn(async () => { + order.push("project-event"); + }), + openThread: vi.fn(async () => { + order.push("open-thread"); + return { threadId }; + }), + }); + + const response = await handleDesktopAppActivationRequest(request, deps); + + expect(order).toEqual(["create", "project-event", "open-thread"]); + expect(response).toMatchObject({ ok: true, projectId: createdProjectId }); + }); + + it("rejects a Windows path when the primary environment is WSL", async () => { + const response = await handleDesktopAppActivationRequest( + { ...request, platform: "win32" }, + dependencies({ getTarget: () => ({ environmentId, platform: "linux" }) }), + ); + + expect(response).toMatchObject({ ok: false, code: "platform-mismatch" }); + }); + + it("returns a project error without opening a thread", async () => { + const openThread = vi.fn(async () => ({ threadId })); + const response = await handleDesktopAppActivationRequest( + request, + dependencies({ + findProject: () => null, + createProject: vi.fn(async () => { + throw new Error("Project path is not available."); + }), + openThread, + }), + ); + + expect(response).toMatchObject({ + ok: false, + code: "project-create-failed", + message: "Project path is not available.", + }); + expect(openThread).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/desktopAppActivation.ts b/apps/web/src/desktopAppActivation.ts new file mode 100644 index 000000000000..e9d3a4d1d0f7 --- /dev/null +++ b/apps/web/src/desktopAppActivation.ts @@ -0,0 +1,119 @@ +import type { + DesktopAppActivationFailure, + DesktopAppActivationRequest, + DesktopAppActivationResponse, + EnvironmentId, + ExecutionEnvironmentPlatformOs, + ProjectId, + ScopedProjectRef, + ThreadId, +} from "@t3tools/contracts"; + +export interface DesktopAppActivationProject { + readonly id: ProjectId; + readonly environmentId: EnvironmentId; + readonly workspaceRoot: string; +} + +export interface DesktopAppActivationTarget { + readonly environmentId: EnvironmentId; + readonly platform: ExecutionEnvironmentPlatformOs; +} + +export interface DesktopAppActivationDependencies { + readonly getTarget: () => DesktopAppActivationTarget | null; + readonly findProject: ( + environmentId: EnvironmentId, + workspaceRoot: string, + ) => DesktopAppActivationProject | null; + readonly createProject: ( + environmentId: EnvironmentId, + workspaceRoot: string, + ) => Promise; + readonly waitForProject: (projectRef: ScopedProjectRef) => Promise; + readonly openThread: ( + projectRef: ScopedProjectRef, + ) => Promise<{ readonly threadId: ThreadId } | null>; +} + +function failure( + requestId: string, + code: DesktopAppActivationFailure["code"], + message: string, +): DesktopAppActivationFailure { + return { version: 1, requestId, ok: false, code, message }; +} + +export function desktopPlatformToEnvironmentOs( + platform: DesktopAppActivationRequest["platform"], +): ExecutionEnvironmentPlatformOs { + return platform === "win32" ? "windows" : platform; +} + +function errorMessage(error: unknown, fallback: string): string { + return error instanceof Error && error.message.trim().length > 0 ? error.message : fallback; +} + +export async function handleDesktopAppActivationRequest( + request: DesktopAppActivationRequest, + dependencies: DesktopAppActivationDependencies, +): Promise { + const target = dependencies.getTarget(); + if (target === null) { + return failure( + request.requestId, + "environment-unavailable", + "The desktop app's primary local environment is not connected.", + ); + } + + const requestPlatform = desktopPlatformToEnvironmentOs(request.platform); + if (requestPlatform !== target.platform) { + return failure( + request.requestId, + "platform-mismatch", + `The command path is for ${requestPlatform}, but the desktop app's primary environment uses ${target.platform}. Cross-platform path mapping is not supported.`, + ); + } + + let projectId = dependencies.findProject(target.environmentId, request.workspaceRoot)?.id ?? null; + if (projectId === null) { + try { + projectId = await dependencies.createProject(target.environmentId, request.workspaceRoot); + await dependencies.waitForProject({ environmentId: target.environmentId, projectId }); + } catch (error) { + return failure( + request.requestId, + "project-create-failed", + errorMessage(error, "T3 Code could not add the project."), + ); + } + } + + try { + const opened = await dependencies.openThread({ + environmentId: target.environmentId, + projectId, + }); + if (opened === null) { + return failure( + request.requestId, + "thread-open-failed", + "T3 Code could not open a new thread for the project.", + ); + } + return { + version: 1, + requestId: request.requestId, + ok: true, + projectId, + threadId: opened.threadId, + }; + } catch (error) { + return failure( + request.requestId, + "thread-open-failed", + errorMessage(error, "T3 Code could not open a new thread for the project."), + ); + } +} diff --git a/apps/web/src/editorLabels.test.ts b/apps/web/src/editorLabels.test.ts new file mode 100644 index 000000000000..42d61deca518 --- /dev/null +++ b/apps/web/src/editorLabels.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { editorLabelForPlatform, openInEditorMenuLabel } from "./editorLabels"; + +describe("editorLabelForPlatform", () => { + it("uses the editor name from the shared editor definitions", () => { + expect(editorLabelForPlatform("cursor", "MacIntel")).toBe("Cursor"); + expect(editorLabelForPlatform("vscode-insiders", "Win32")).toBe("VS Code Insiders"); + }); + + it.each([ + ["MacIntel", "Finder"], + ["Win32", "Explorer"], + ["Linux x86_64", "Files"], + ])("uses the platform file-manager name on %s", (platform, label) => { + expect(editorLabelForPlatform("file-manager", platform)).toBe(label); + }); +}); + +describe("openInEditorMenuLabel", () => { + it("names the preferred editor", () => { + expect(openInEditorMenuLabel("zed")).toBe("Open in Zed"); + }); + + it("keeps the generic label for the default file handler and missing preferences", () => { + expect(openInEditorMenuLabel("file-manager")).toBe("Open in editor"); + expect(openInEditorMenuLabel(null)).toBe("Open in editor"); + }); +}); diff --git a/apps/web/src/editorLabels.ts b/apps/web/src/editorLabels.ts new file mode 100644 index 000000000000..7d44bc328b74 --- /dev/null +++ b/apps/web/src/editorLabels.ts @@ -0,0 +1,19 @@ +import { EDITORS, type EditorId } from "@t3tools/contracts"; + +import { getLocalFileManagerName } from "~/lib/utils"; + +const editorLabels = new Map(EDITORS.map((editor) => [editor.id, editor.label])); + +export function editorLabelForPlatform(editorId: EditorId, platform: string): string { + if (editorId === "file-manager") { + return getLocalFileManagerName(platform); + } + + return editorLabels.get(editorId) ?? "Editor"; +} + +export function openInEditorMenuLabel(editorId: EditorId | null): string { + return editorId === null || editorId === "file-manager" + ? "Open in editor" + : `Open in ${editorLabels.get(editorId) ?? "Editor"}`; +} diff --git a/apps/web/src/environmentGrouping.test.ts b/apps/web/src/environmentGrouping.test.ts index 9029f1204d36..9bd7a3e92428 100644 --- a/apps/web/src/environmentGrouping.test.ts +++ b/apps/web/src/environmentGrouping.test.ts @@ -323,6 +323,74 @@ describe("environment grouping", () => { expect(entries[1]?.group.displayName).toBe("separate"); }); + it("keeps the current environment when available and falls back otherwise", () => { + const currentPrimary = makeProject({ repositoryIdentity }); + const currentRemote = makeProject({ + id: ProjectId.make("current-remote"), + environmentId: remoteEnvironmentId, + repositoryIdentity, + }); + const destinationRepositoryIdentity = { + canonicalKey: "github.com/example/destination", + locator: { + source: "git-remote" as const, + remoteName: "origin", + remoteUrl: "https://github.com/example/destination.git", + }, + }; + const destinationPrimary = makeProject({ + id: ProjectId.make("destination-primary"), + title: "destination", + workspaceRoot: "/tmp/destination", + repositoryIdentity: destinationRepositoryIdentity, + }); + const destinationRemote = makeProject({ + id: ProjectId.make("destination-remote"), + environmentId: remoteEnvironmentId, + title: "destination", + workspaceRoot: "/remote/destination", + repositoryIdentity: destinationRepositoryIdentity, + }); + const fallbackPrimary = makeProject({ + id: ProjectId.make("fallback-primary"), + title: "fallback", + workspaceRoot: "/tmp/fallback", + }); + const groups = buildSidebarProjectSnapshots({ + projects: [ + currentPrimary, + currentRemote, + destinationPrimary, + destinationRemote, + fallbackPrimary, + ], + settings: defaultGroupingSettings, + primaryEnvironmentId, + resolveEnvironmentLabel: () => null, + }); + + const entries = buildSidebarProjectPickerEntries({ + groups, + preferredProjectRef: { + environmentId: remoteEnvironmentId, + projectId: currentRemote.id, + }, + }); + const destination = entries.find( + (entry) => entry.group.projectKey === destinationRepositoryIdentity.canonicalKey, + ); + const fallback = entries.find((entry) => entry.group.displayName === "fallback"); + + expect(destination?.targetProject).toMatchObject({ + environmentId: remoteEnvironmentId, + id: destinationRemote.id, + }); + expect(fallback?.targetProject).toMatchObject({ + environmentId: primaryEnvironmentId, + id: fallbackPrimary.id, + }); + }); + it("keeps manual project order when building grouped sidebar entries", () => { const primary = makeProject({ repositoryIdentity }); const remote = makeProject({ diff --git a/apps/web/src/environments/primary/context.ts b/apps/web/src/environments/primary/context.ts index e1021a7feb4d..48017ac29e38 100644 --- a/apps/web/src/environments/primary/context.ts +++ b/apps/web/src/environments/primary/context.ts @@ -95,12 +95,7 @@ export function resolveInitialPrimaryEnvironmentDescriptor(): Promise - `[${count} earlier message(s) omitted to stay within input limits.]`; - -function messageRoleLabel(message: ChatMessage): "USER" | "ASSISTANT" { - return message.role === "assistant" ? "ASSISTANT" : "USER"; -} - -function attachmentSummary(message: ChatMessage): string | null { - const imageAttachments = message.attachments?.filter((attachment) => attachment.type === "image"); - const count = imageAttachments?.length ?? 0; - if (count === 0) { - return null; - } - - const names = imageAttachments?.slice(0, 3).map((image) => image.name) ?? []; - const namesSummary = names.join(", "); - const extraCount = count - names.length; - const extraSummary = extraCount > 0 ? ` (+${extraCount} more)` : ""; - return `[Attached image${count === 1 ? "" : "s"}: ${namesSummary}${extraSummary}]`; -} - -function buildMessageBlock(message: ChatMessage): string { - const text = message.text; - const attachments = attachmentSummary(message); - - if (text && attachments) { - return `${messageRoleLabel(message)}:\n${text}\n${attachments}`; - } - if (text) { - return `${messageRoleLabel(message)}:\n${text}`; - } - if (attachments) { - return `${messageRoleLabel(message)}:\n${attachments}`; - } - return `${messageRoleLabel(message)}:\n(empty message)`; -} - -function finalizeWithPrompt( - transcriptBody: string, - latestPrompt: string, - maxChars: number, -): string | null { - const text = `${BOOTSTRAP_PREAMBLE}\n\n${TRANSCRIPT_HEADER}\n${transcriptBody}\n\n${LATEST_PROMPT_HEADER}\n${latestPrompt}`; - return text.length <= maxChars ? text : null; -} - -export function buildBootstrapInput( - previousMessages: ChatMessage[], - latestPrompt: string, - maxChars: number, -): BootstrapInputResult { - const budget = Number.isFinite(maxChars) ? Math.max(1, Math.floor(maxChars)) : 1; - const promptOnly = latestPrompt.length <= budget ? latestPrompt : latestPrompt.slice(0, budget); - - if (previousMessages.length === 0) { - return { - text: promptOnly, - includedCount: 0, - omittedCount: 0, - truncated: promptOnly.length !== latestPrompt.length, - }; - } - - const newestFirstBlocks: string[] = []; - for (let index = previousMessages.length - 1; index >= 0; index -= 1) { - const message = previousMessages[index]; - if (!message) continue; - newestFirstBlocks.push(buildMessageBlock(message)); - } - - if (newestFirstBlocks.length === 0) { - return { - text: promptOnly, - includedCount: 0, - omittedCount: previousMessages.length, - truncated: true, - }; - } - - // Include a contiguous suffix from newest to oldest, then reverse to chronological. - let includedNewestFirst: string[] = []; - for (const block of newestFirstBlocks) { - const nextNewestFirst = [...includedNewestFirst, block]; - const nextChronological = nextNewestFirst.toReversed(); - const omittedCount = newestFirstBlocks.length - nextChronological.length; - const transcriptBody = - omittedCount > 0 - ? `${OMITTED_SUMMARY(omittedCount)}\n\n${nextChronological.join("\n\n")}` - : nextChronological.join("\n\n"); - if (!finalizeWithPrompt(transcriptBody, latestPrompt, budget)) { - break; - } - includedNewestFirst = nextNewestFirst; - } - - let includedChronological = includedNewestFirst.toReversed(); - while (true) { - const omittedCount = newestFirstBlocks.length - includedChronological.length; - const transcriptBody = - omittedCount > 0 - ? includedChronological.length > 0 - ? `${OMITTED_SUMMARY(omittedCount)}\n\n${includedChronological.join("\n\n")}` - : OMITTED_SUMMARY(omittedCount) - : includedChronological.join("\n\n"); - const finalized = finalizeWithPrompt(transcriptBody, latestPrompt, budget); - if (finalized) { - return { - text: finalized, - includedCount: includedChronological.length, - omittedCount, - truncated: omittedCount > 0 || latestPrompt.length !== promptOnly.length, - }; - } - - if (includedChronological.length === 0) { - return { - text: promptOnly, - includedCount: 0, - omittedCount: previousMessages.length, - truncated: true, - }; - } - - includedChronological = includedChronological.slice(1); - } -} diff --git a/apps/web/src/hooks/useDefaultTheme.test.ts b/apps/web/src/hooks/useDefaultTheme.test.ts new file mode 100644 index 000000000000..29b79a591628 --- /dev/null +++ b/apps/web/src/hooks/useDefaultTheme.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { defaultThemeGeneration, defaultThemeToApply } from "./useDefaultTheme"; + +const BASE = { + environmentId: "env-1", + defaultTheme: "nightfall", + defaultThemeSetAt: "2026-08-28T00:00:00.000Z", + appliedGeneration: null, + resolves: true, +} as const; + +describe("default theme adoption", () => { + it("applies a set this client has not seen", () => { + expect(defaultThemeToApply(BASE)).toBe( + defaultThemeGeneration(BASE.defaultTheme, BASE.defaultThemeSetAt), + ); + }); + + it("does not replay a generation it already applied", () => { + const generation = defaultThemeGeneration(BASE.defaultTheme, BASE.defaultThemeSetAt); + expect(defaultThemeToApply({ ...BASE, appliedGeneration: generation })).toBe(null); + }); + + // `t3 theme set` of a theme this client already wears must still act, which + // is why the generation carries the set time and not just the value. + it("applies the same theme again when the environment re-sets it", () => { + const applied = defaultThemeGeneration(BASE.defaultTheme, "2026-08-28T00:00:00.000Z"); + const next = defaultThemeToApply({ + ...BASE, + defaultThemeSetAt: "2026-08-29T00:00:00.000Z", + appliedGeneration: applied, + }); + expect(next).not.toBe(null); + expect(next).not.toBe(applied); + }); + + // Environments provisioned before the timestamp existed. + it("falls back to once-per-value without a set time", () => { + const generation = defaultThemeGeneration("nightfall", ""); + expect(defaultThemeToApply({ ...BASE, defaultThemeSetAt: "", appliedGeneration: null })).toBe( + generation, + ); + expect( + defaultThemeToApply({ ...BASE, defaultThemeSetAt: "", appliedGeneration: generation }), + ).toBe(null); + }); + + // The setting and the palette it names arrive independently. + it("waits for a theme that has not arrived yet", () => { + expect(defaultThemeToApply({ ...BASE, resolves: false })).toBe(null); + }); + + it("leaves a client alone with no environment or no theme set", () => { + expect(defaultThemeToApply({ ...BASE, environmentId: null })).toBe(null); + expect(defaultThemeToApply({ ...BASE, defaultTheme: "" })).toBe(null); + }); +}); diff --git a/apps/web/src/hooks/useDefaultTheme.ts b/apps/web/src/hooks/useDefaultTheme.ts new file mode 100644 index 000000000000..01c908f73698 --- /dev/null +++ b/apps/web/src/hooks/useDefaultTheme.ts @@ -0,0 +1,115 @@ +import { useAtomValue } from "@effect/atom-react"; +import { useEffect } from "react"; + +import { primaryEnvironmentIdAtom } from "../state/primaryEnvironment"; +import { primaryServerSettingsAtom } from "../state/server"; +import { getThemeDefinition, singleAppearanceOf } from "../themePalette"; +import { useEnvironmentThemeDefinitions } from "./useEnvironmentTheme"; +import { useTheme } from "./useTheme"; + +/** + * Scoped per environment: each machine's `t3 theme set` is its own act, so + * hopping between primary environments neither replays one environment's + * theme over the user's pick nor swallows another's. + */ +const APPLIED_DEFAULT_THEME_STORAGE_PREFIX = "t3code:default-theme-applied:v2:"; + +/** + * One generation per set: keyed on when the theme was set, not just its + * value, so re-asserting the same theme still acts. Environments provisioned + * by builds without the timestamp degrade to applying once per value. + */ +export function defaultThemeGeneration(theme: string, setAt: string): string { + return setAt.length > 0 ? `${theme}@${setAt}` : theme; +} + +/** + * The generation to apply, or null to leave this client alone. Pure so the + * rule -- apply once per set, never replay one already applied, wait for a + * theme that has not arrived yet -- can be tested without a renderer. + */ +export function defaultThemeToApply(input: { + readonly environmentId: string | null; + readonly defaultTheme: string; + readonly defaultThemeSetAt: string; + readonly appliedGeneration: string | null; + readonly resolves: boolean; +}): string | null { + if (input.environmentId === null || input.defaultTheme.length === 0) return null; + const generation = defaultThemeGeneration(input.defaultTheme, input.defaultThemeSetAt); + if (input.appliedGeneration === generation) return null; + // The setting and the palette it names arrive independently, so an id that + // does not resolve yet is not a failure -- the effect runs again when the + // published set changes. + if (!input.resolves) return null; + return generation; +} + +function readAppliedGeneration(storageKey: string): string | null { + if (typeof window === "undefined") return null; + try { + return window.localStorage.getItem(storageKey); + } catch { + return null; + } +} + +function writeAppliedGeneration(storageKey: string, generation: string): void { + try { + window.localStorage.setItem(storageKey, generation); + } catch { + // Unrecordable means the next config event applies again; harmless. + } +} + +/** + * Applies the environment's theme (`t3 theme set `). Each set switches + * this client once — live when connected, on the next connect otherwise — + * and then steps aside: a theme the user picks in Settings afterwards wins + * until the environment's theme is set again. The environment's own published + * themes are valid targets, which is why this waits for an id that does not + * resolve yet — the setting and the palette it names arrive independently. + */ +export function useDefaultThemeAdoption(): void { + const environmentId = useAtomValue(primaryEnvironmentIdAtom); + const settings = useAtomValue(primaryServerSettingsAtom); + const { defaultTheme, defaultThemeSetAt } = settings; + const { setTheme, setAppearanceMode } = useTheme(); + // Re-runs adoption when a late-arriving published theme makes the + // requested id resolvable. + const environmentThemes = useEnvironmentThemeDefinitions(); + + useEffect(() => { + if (typeof window === "undefined" || environmentId === null) return; + const storageKey = `${APPLIED_DEFAULT_THEME_STORAGE_PREFIX}${environmentId}`; + const definition = getThemeDefinition(defaultTheme); + const generation = defaultThemeToApply({ + environmentId, + defaultTheme, + defaultThemeSetAt, + appliedGeneration: readAppliedGeneration(storageKey), + resolves: definition !== null, + }); + if (generation === null || definition === null) return; + + // Deliberately not the card's rule. Clicking a card is a user choosing one + // half of their own mix; a set theme is the environment saying what this + // client opens on, so it takes the base preference and, for a + // single-appearance theme, the matching mode -- otherwise a dark-only + // theme on a light client is recorded as applied while nothing changes. + if (!setTheme(defaultTheme)) return; + const half = singleAppearanceOf(definition); + // Recorded only once both land: marking the generation applied while the + // appearance switch failed would leave a dark-only theme rendering its + // light half with no retry. + if (half !== null && !setAppearanceMode(half)) return; + writeAppliedGeneration(storageKey, generation); + }, [ + environmentId, + defaultTheme, + defaultThemeSetAt, + environmentThemes, + setTheme, + setAppearanceMode, + ]); +} diff --git a/apps/web/src/hooks/useEnvironmentTheme.test.ts b/apps/web/src/hooks/useEnvironmentTheme.test.ts new file mode 100644 index 000000000000..fd8785fc0d21 --- /dev/null +++ b/apps/web/src/hooks/useEnvironmentTheme.test.ts @@ -0,0 +1,212 @@ +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; + +import { environmentThemeDefinition, publishedThemeDefinitions } from "./useEnvironmentTheme"; +import { + getDefaultThemeColors, + getThemeDefinition, + installCustomTheme, + invalidateCustomThemes, + setEnvironmentThemes, + THEME_COLOR_ROLES, +} from "../themePalette"; + +const NIGHTFALL_THEME = { + id: "nightfall", + name: "Nightfall", + appearance: "dark", + canvas: "#1a1b26", + accent: "#7aa2f7", +} as const; + +afterEach(() => { + setEnvironmentThemes([]); +}); + +describe("environment themes", () => { + it("generates every role from the two published seeds", () => { + const theme = environmentThemeDefinition(NIGHTFALL_THEME); + + expect(theme.id).toBe("nightfall"); + expect(theme.label).toBe("Nightfall"); + expect(theme.appearance).toBe("dark"); + for (const role of THEME_COLOR_ROLES) { + expect(theme.colors[role], `missing ${role}`).toBeTruthy(); + } + }); + + it("layers published roles over the generated palette", () => { + const generated = environmentThemeDefinition(NIGHTFALL_THEME); + const overridden = environmentThemeDefinition({ + ...NIGHTFALL_THEME, + colors: { terminalSelection: "#292e42", error: "#f7768e" }, + }); + + expect(overridden.colors.terminalSelection).not.toBe(generated.colors.terminalSelection); + expect(overridden.colors.error).not.toBe(generated.colors.error); + // Roles the machine did not publish keep the generated value. + expect(overridden.colors.sidebar).toBe(generated.colors.sidebar); + }); + + // The standard exported theme file — the Download button's output — is a + // valid published theme, so any shared theme can be dropped into the + // machine's themes directory as-is. + it("renders an exported theme file on the stock defaults", () => { + const theme = environmentThemeDefinition({ + id: "shared-light", + version: 1, + name: "Shared Light", + appearance: "light", + colors: { canvas: "oklch(0.95 0.01 250)", accent: "#1e66f5" }, + variants: { dark: { canvas: "#1a1b26" } }, + }); + + expect(theme.label).toBe("Shared Light"); + expect(theme.colors.accent).toBeTruthy(); + expect(theme.variants?.dark?.canvas).toBeTruthy(); + for (const role of THEME_COLOR_ROLES) { + expect(theme.colors[role], `missing ${role}`).toBeTruthy(); + expect(theme.variants?.dark?.[role], `missing dark ${role}`).toBeTruthy(); + } + }); + + // The generator follows the seed canvas's luminance, so a dark theme's + // seeds must never produce its light variant: the variant builds on that + // appearance's stock defaults instead. + it("builds a seeded theme's variant on the variant appearance's defaults", () => { + const theme = environmentThemeDefinition({ + ...NIGHTFALL_THEME, + variants: { light: { canvas: "#eff1f5" } }, + }); + + const lightDefaults = getDefaultThemeColors("light"); + expect(theme.variants?.light?.text).toBe(lightDefaults.text); + expect(theme.variants?.light?.canvas).not.toBe(theme.colors.canvas); + }); + + // A published `t3-iris.json` would show its palette on a card that applies + // the built-in, and a published `dark.json` would capture everyone whose + // stored preference is the stock "dark". Reserved ids never become cards. + it("drops published themes with reserved ids", () => { + const definitions = publishedThemeDefinitions([ + NIGHTFALL_THEME, + { ...NIGHTFALL_THEME, id: "t3-iris", name: "Impostor Iris" }, + { ...NIGHTFALL_THEME, id: "ocean", name: "Impostor Ocean" }, + { ...NIGHTFALL_THEME, id: "dark", name: "Impostor Dark" }, + ]); + + expect(definitions.map((definition) => definition.id)).toEqual(["nightfall"]); + }); + + it("drops published palettes with no usable colors", () => { + const theme = { id: "invalid", name: "Invalid", appearance: "dark" } as const; + const definitions = publishedThemeDefinitions([ + { ...theme, colors: { canvas: "not-a-color" } }, + { ...theme, id: "unknown-roles", colors: { futureRole: "#ffffff" } }, + { + ...theme, + id: "ignored-variant", + colors: { canvas: "not-a-color" }, + variants: { dark: { canvas: "#112233" } }, + }, + ]); + + expect(definitions).toEqual([]); + }); + + it("keeps seeds, partial palettes, and usable alternate appearances", () => { + const theme = { name: "Shared", appearance: "dark" } as const; + const definitions = publishedThemeDefinitions([ + NIGHTFALL_THEME, + { + ...theme, + id: "partial", + colors: { canvas: "not-a-color", text: "#ffffff", futureRole: "#ffffff" }, + }, + { + ...theme, + id: "light-variant", + colors: { canvas: "not-a-color" }, + variants: { light: { canvas: "#eff1f5" } }, + }, + ]); + + expect(definitions.map((definition) => definition.id)).toEqual([ + "nightfall", + "partial", + "light-variant", + ]); + }); + + // A machine may publish roles a newer client added; an older one has to + // ignore them rather than render a broken palette. + it("ignores published roles this build does not render", () => { + const theme = environmentThemeDefinition({ + ...NIGHTFALL_THEME, + colors: { notARole: "#ff0000", text: "#ffffff" }, + }); + + expect(theme.colors).not.toHaveProperty("notARole"); + for (const role of THEME_COLOR_ROLES) { + expect(theme.colors[role], `missing ${role}`).toBeTruthy(); + } + }); + + // ThemeEditorPanel opens Duplicate in the guided editor for managed themes, + // and the guided editor regenerates from canvas and accent -- which would + // discard any role the machine tuned by hand. + it("marks only the pure seeded form as managed", () => { + expect(environmentThemeDefinition(NIGHTFALL_THEME).managed).toBe(true); + expect( + environmentThemeDefinition({ ...NIGHTFALL_THEME, colors: { error: "#f7768e" } }).managed, + ).toBeUndefined(); + expect( + environmentThemeDefinition({ + id: "shared-light", + version: 1, + name: "Shared Light", + appearance: "light", + colors: { canvas: "#eff1f5", accent: "#1e66f5" }, + }).managed, + ).toBeUndefined(); + }); + + it("resolves published ids only while the machine publishes them", () => { + expect(getThemeDefinition("nightfall")).toBe(null); + + setEnvironmentThemes([environmentThemeDefinition(NIGHTFALL_THEME)]); + expect(getThemeDefinition("nightfall")?.label).toBe("Nightfall"); + + // The palettes are never saved, so they have to disappear with the + // machine that published them rather than linger as stale entries. + setEnvironmentThemes([]); + expect(getThemeDefinition("nightfall")).toBe(null); + }); + + // Published ids share one namespace with the user's saved themes, and the + // user was here first: their theme keeps working even if the machine later + // publishes under the same id. + it("lets a theme the user saved win an id collision", () => { + const store = new Map(); + vi.stubGlobal("window", { + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + localStorage: { + getItem: (key: string) => store.get(key) ?? null, + setItem: (key: string, value: string) => store.set(key, value), + removeItem: (key: string) => store.delete(key), + }, + }); + invalidateCustomThemes(); + try { + const environment = environmentThemeDefinition(NIGHTFALL_THEME); + setEnvironmentThemes([environment]); + installCustomTheme({ ...environment, label: "My Nightfall" }); + + expect(getThemeDefinition("nightfall")?.label).toBe("My Nightfall"); + } finally { + vi.unstubAllGlobals(); + invalidateCustomThemes(); + } + expect(getThemeDefinition("nightfall")?.label).toBe("Nightfall"); + }); +}); diff --git a/apps/web/src/hooks/useEnvironmentTheme.ts b/apps/web/src/hooks/useEnvironmentTheme.ts new file mode 100644 index 000000000000..53feabcf6a4f --- /dev/null +++ b/apps/web/src/hooks/useEnvironmentTheme.ts @@ -0,0 +1,125 @@ +import type { EnvironmentTheme } from "@t3tools/contracts"; +import { useAtomValue } from "@effect/atom-react"; +import * as Equal from "effect/Equal"; +import { useEffect, useRef, useSyncExternalStore } from "react"; + +import { primaryServerEnvironmentThemesAtom } from "../state/server"; +import { + createVividThemeColors, + getDefaultThemeColors, + getEnvironmentThemes, + isReservedThemeId, + lenientThemeColorOverrides, + setEnvironmentThemes, + subscribeToCustomThemes, + type ThemeAppearance, + type ThemeColors, + type ThemeDefinition, +} from "../themePalette"; +import { useTheme } from "./useTheme"; + +function publishedThemeColors( + theme: EnvironmentTheme, + appearance: ThemeAppearance, + colors: Readonly> | undefined, +): ThemeColors { + // Seeds generate the base with the guided theme editor's generator, so a + // desktop theme arrives as a coherent T3 Code palette rather than a foreign + // one — but only for the appearance they describe. A variant builds on that + // appearance's stock defaults: the generator follows the seed canvas's + // luminance, so dark seeds would give a light variant unreadable colors. + const base = + appearance === theme.appearance && theme.canvas !== undefined && theme.accent !== undefined + ? createVividThemeColors(appearance, theme.canvas, theme.accent) + : getDefaultThemeColors(appearance); + return { ...base, ...lenientThemeColorOverrides(colors ?? {}) }; +} + +/** + * A published theme as the theme library renders it. Both published forms are + * accepted: the seeded short form a desktop generates, and the standard + * exported theme file the Download button produces — so any theme someone + * shared can be dropped into the machine's themes directory as-is. + */ +export function environmentThemeDefinition(theme: EnvironmentTheme): ThemeDefinition { + const variants: Partial> = {}; + for (const [variantAppearance, variantColors] of Object.entries(theme.variants ?? {})) { + if (variantAppearance === theme.appearance) continue; + variants[variantAppearance as ThemeAppearance] = publishedThemeColors( + theme, + variantAppearance as ThemeAppearance, + variantColors, + ); + } + + return { + id: theme.id, + label: theme.name, + appearance: theme.appearance, + colors: publishedThemeColors(theme, theme.appearance, theme.colors), + ...(Object.keys(variants).length > 0 ? { variants } : {}), + // Only the pure seeded form is guided-generator output. An exported file, + // or seeds carrying explicit role overrides, must open Duplicate in the + // advanced editor -- the guided one regenerates from canvas and accent and + // would discard whatever the machine hand-tuned. + ...(theme.canvas !== undefined && + theme.accent !== undefined && + theme.colors === undefined && + theme.variants === undefined + ? { managed: true } + : {}), + }; +} + +/** + * Published palettes this client can render. Reserved ids are dropped here rather + * than rendered: a published `t3-iris.json` would show this palette on its + * card while "Use" resolved the built-in, and a published `dark.json` would + * capture everyone whose stored preference is the stock `"dark"`. + */ +export function publishedThemeDefinitions( + themes: ReadonlyArray, +): ReadonlyArray { + return themes + .filter((theme) => { + if (isReservedThemeId(theme.id)) return false; + if (theme.canvas !== undefined && theme.accent !== undefined) return true; + const otherAppearance = theme.appearance === "dark" ? "light" : "dark"; + return [theme.colors, theme.variants?.[otherAppearance]].some( + (colors) => + colors !== undefined && Object.keys(lenientThemeColorOverrides(colors)).length > 0, + ); + }) + .map(environmentThemeDefinition); +} + +/** The published themes as library entries; empty while none are published. */ +export function useEnvironmentThemeDefinitions(): ReadonlyArray { + return useSyncExternalStore(subscribeToCustomThemes, getEnvironmentThemes, () => []); +} + +/** + * Keeps the machine's published themes in the theme library for as long as + * the primary environment publishes them. A client with a published theme + * selected retints the moment the machine rewrites it; everyone else just + * gains cards in the theme library. + */ +export function useEnvironmentThemeSync(): void { + const published = useAtomValue(primaryServerEnvironmentThemesAtom); + const { refreshTheme } = useTheme(); + const lastPublished = useRef | null>(null); + + useEffect(() => { + // Every reconnect snapshot delivers a fresh but usually identical array; + // regenerating palettes and repainting the document for it is exactly the + // wasted-frame class this codebase audits for. + if (lastPublished.current !== null && Equal.equals(lastPublished.current, published)) return; + lastPublished.current = published; + + // The palette is painted from a snapshot taken when the theme last + // changed, so new colors only land if the active theme is re-applied. + if (setEnvironmentThemes(publishedThemeDefinitions(published))) { + refreshTheme({ preservePreview: true }); + } + }, [published, refreshTheme]); +} diff --git a/apps/web/src/hooks/useEnvironmentThemeSync.test.ts b/apps/web/src/hooks/useEnvironmentThemeSync.test.ts new file mode 100644 index 000000000000..1f22e0617bcc --- /dev/null +++ b/apps/web/src/hooks/useEnvironmentThemeSync.test.ts @@ -0,0 +1,181 @@ +import type { EnvironmentTheme } from "@t3tools/contracts"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; + +const NIGHTFALL_THEME = { + id: "nightfall", + name: "Nightfall", + appearance: "dark", + canvas: "#1a1b26", + accent: "#7aa2f7", +} as const satisfies EnvironmentTheme; + +const LIGHT_THEME = { + ...NIGHTFALL_THEME, + appearance: "light", + canvas: "#eff1f5", +} as const satisfies EnvironmentTheme; + +async function setupThemeSync(mode: "dark" | "system" = "dark") { + const storage = new Map([["t3code:theme", NIGHTFALL_THEME.id]]); + const styles = new Map(); + const classes = new Set(); + const root = { + dataset: {} as Record, + style: { + setProperty: (name: string, value: string) => styles.set(name, value), + removeProperty: (name: string) => styles.delete(name), + }, + classList: { + add: (name: string) => classes.add(name), + remove: (name: string) => classes.delete(name), + toggle: (name: string, enabled: boolean) => + enabled ? classes.add(name) : classes.delete(name), + contains: (name: string) => classes.has(name), + }, + }; + vi.stubGlobal("document", { documentElement: root }); + vi.stubGlobal("window", { + localStorage: { + getItem: (key: string) => storage.get(key) ?? null, + setItem: (key: string, value: string) => storage.set(key, value), + removeItem: (key: string) => storage.delete(key), + }, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + matchMedia: () => ({ + matches: true, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }), + }); + vi.stubGlobal("requestAnimationFrame", vi.fn()); + + let published: ReadonlyArray = [NIGHTFALL_THEME]; + let readSnapshot: (() => unknown) | undefined; + const effects: Array<() => void> = []; + const lastPublished: { current: ReadonlyArray | null } = { current: null }; + vi.doMock("react", () => ({ + useCallback: (callback: A) => callback, + useEffect: (effect: () => void) => effects.push(effect), + useRef: () => lastPublished, + useSyncExternalStore: (_subscribe: unknown, getSnapshot: () => unknown) => { + readSnapshot = getSnapshot; + return getSnapshot(); + }, + })); + vi.doMock("@effect/atom-react", () => ({ useAtomValue: () => published })); + vi.doMock("../state/server", () => ({ primaryServerEnvironmentThemesAtom: {} })); + + const palette = await import("../themePalette"); + storage.set(palette.THEME_APPEARANCE_MODE_STORAGE_KEY, mode); + const { useTheme } = await import("./useTheme"); + const { useEnvironmentThemeSync } = await import("./useEnvironmentTheme"); + const flushEffects = () => { + for (const effect of effects.splice(0)) effect(); + }; + const publish = (themes: ReadonlyArray) => { + published = themes; + useEnvironmentThemeSync(); + flushEffects(); + // A changed store snapshot also runs the consumer's passive theme effect. + const theme = useTheme(); + flushEffects(); + return theme; + }; + publish(published); + + return { publish, palette, root, styles, readSnapshot: () => readSnapshot?.() }; +} + +afterEach(() => { + vi.doUnmock("react"); + vi.doUnmock("@effect/atom-react"); + vi.doUnmock("../state/server"); + vi.resetModules(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe("published theme refresh", () => { + it.each(["dark", "system"] as const)( + "updates the React snapshot when appearance changes in %s mode", + async (mode) => { + const { publish, root, readSnapshot } = await setupThemeSync(mode); + const darkSnapshot = readSnapshot(); + + expect(publish([LIGHT_THEME]).resolvedTheme).toBe("light"); + expect(root.classList.contains("dark")).toBe(false); + const lightSnapshot = readSnapshot(); + expect(lightSnapshot).not.toBe(darkSnapshot); + + expect(publish([NIGHTFALL_THEME]).resolvedTheme).toBe("dark"); + expect(root.classList.contains("dark")).toBe(true); + expect(readSnapshot()).not.toBe(lightSnapshot); + }, + ); + + it("keeps the React snapshot stable for color-only changes", async () => { + const { publish, palette, styles, readSnapshot } = await setupThemeSync(); + const before = readSnapshot(); + const accentVariable = palette.getThemeColorVariable("accent"); + const previousAccent = styles.get(accentVariable); + + publish([{ ...NIGHTFALL_THEME, accent: "#ff0000" }]); + + expect(styles.get(accentVariable)).not.toBe(previousAccent); + expect(readSnapshot()).toBe(before); + }); + + it("keeps a draft visible while updating the stored selection underneath it", async () => { + const { publish, palette, root, styles } = await setupThemeSync(); + const draft = { ...palette.getDefaultThemeColors("light"), canvas: "#234567" }; + palette.applyThemeColorPreview(draft, "light"); + const canvasVariable = palette.getThemeColorVariable("canvas"); + + const expectPreview = () => { + expect(root.dataset.themeId).toBe(palette.THEME_PREVIEW_ID); + expect(styles.get(canvasVariable)).toBe(draft.canvas); + expect(root.classList.contains("dark")).toBe(false); + }; + expect(publish([LIGHT_THEME]).resolvedTheme).toBe("light"); + expectPreview(); + publish([LIGHT_THEME, { ...NIGHTFALL_THEME, id: "unused-theme" }]); + expectPreview(); + expect(publish([]).theme).toBe("system"); + expectPreview(); + + const current = publish([{ ...NIGHTFALL_THEME, canvas: "#112233" }]); + expect(current.theme).toBe(NIGHTFALL_THEME.id); + expect(current.resolvedTheme).toBe("dark"); + expectPreview(); + + current.refreshTheme(); + expect(root.dataset.themeId).toBe(NIGHTFALL_THEME.id); + expect(styles.get(canvasVariable)).toBe( + palette.getThemeDefinition(NIGHTFALL_THEME.id)?.colors.canvas, + ); + expect(root.classList.contains("dark")).toBe(true); + expect(palette.getThemePreviewSidebarArtwork()).toBeNull(); + }); + + it("keeps a draft visible when default adoption changes the theme and appearance", async () => { + const { publish, palette, root, readSnapshot } = await setupThemeSync(); + const current = publish([NIGHTFALL_THEME]); + palette.applyThemeColorPreview(palette.getDefaultThemeColors("dark"), "dark"); + + expect(current.setTheme("ocean")).toBe(true); + expect(current.setAppearanceMode("light")).toBe(true); + expect(readSnapshot()).toMatchObject({ + theme: "ocean", + appearanceMode: "light", + resolvedTheme: "light", + }); + expect(root.dataset.themeId).toBe(palette.THEME_PREVIEW_ID); + expect(root.classList.contains("dark")).toBe(true); + + current.refreshTheme(); + expect(root.dataset.themeId).toBe("ocean"); + expect(root.classList.contains("dark")).toBe(false); + expect(palette.getThemePreviewSidebarArtwork()).toBeNull(); + }); +}); diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index ed88a2033296..0df26f455e04 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -23,8 +23,12 @@ import { selectProjectGroupingSettings, } from "../logicalProject"; import { resolveDefaultThreadEnvMode } from "@t3tools/shared/threadEnvMode"; -import { readThreadShell, useProjects, useThread } from "../state/entities"; -import { resolveNewDraftStartFromOrigin } from "../lib/chatThreadActions"; +import { readProjects, readThreadShell, useProjects, useThread } from "../state/entities"; +import { + hasExplicitComposerModelSelection, + resolveNewDraftStartFromOrigin, + resolveNewThreadModelSelectionOverride, +} from "../lib/chatThreadActions"; import { readT3ProjectFileDefaultThreadEnvMode } from "../lib/t3ProjectFileDefaults"; import { primaryServerSettingsAtom } from "../state/server"; import { resolveThreadRouteTarget } from "../threadRoutes"; @@ -51,7 +55,6 @@ function pickExplicitWorkspaceOptions(options: NewThreadWorkspaceOptions | undef } export function useNewThreadHandler() { - const projects = useProjects(); // New-thread defaults are a user preference, and the settings UI only ever // edits the primary environment's settings.json. Reading the target // environment's own settings here would silently reset remote projects to @@ -74,36 +77,27 @@ export function useNewThreadHandler() { envMode?: DraftThreadEnvMode; startFromOrigin?: boolean; replace?: boolean; - /** - * Move the viewed draft's typed content (prompt + images) into the - * draft this request lands on. Set by the draft repo picker: the - * user started writing in the wrong project and the text should - * follow them. Explicit new-thread surfaces leave this unset and - * keep mint-fresh semantics. - */ - carryComposerContent?: boolean; }, // Which draft the thread ended up in, so a caller that has something to put in it — a // prepared checkout, a task to write — addresses that one rather than looking the project // up again and finding whichever draft it happens to hold. ): Promise<{ draftId: DraftId; threadId: ThreadId } | null> => { + const projects = readProjects(); const { getComposerDraft, getDraftSessionByLogicalProjectKey, getDraftSession, getDraftThread, applyStickyState, - moveComposerPromptAndImages, setDraftThreadContext, setLogicalProjectDraftThreadId, setModelSelection, } = useComposerDraftStore.getState(); const currentRouteTarget = getCurrentRouteTarget(); - // A new thread carries the user's *working mode* from the thread being - // viewed: model (including options like reasoning effort and context - // window), permission mode, and interaction mode. Branch, worktree, and - // env mode never carry implicitly — those come from the configured - // defaults unless the caller passes them explicitly. + // A new thread carries the user's working mode from the thread being + // viewed. The target project's configured model still wins; runtime and + // interaction modes carry independently. Branch, worktree, and env mode + // come from configured defaults unless the caller passes them explicitly. const carrySourceShell = currentRouteTarget?.kind === "server" ? readThreadShell(currentRouteTarget.threadRef) @@ -135,32 +129,19 @@ export function useNewThreadHandler() { carrySourceShell?.interactionMode ?? carrySourceDraft?.interactionMode ?? null; - // Content only moves when the caller opted in and the user is looking - // at a draft. The content check happens at move time, not here: the - // paths below await, and text typed during those awaits must still - // come along. - const carryContentSourceDraftId = - options?.carryComposerContent === true && currentRouteTarget?.kind === "draft" - ? currentRouteTarget.draftId - : null; - const carryComposerContentTo = (destinationDraftId: DraftId) => { - if ( - carryContentSourceDraftId && - carryContentSourceDraftId !== destinationDraftId && - // Never clobber a destination the user already invested in — the - // move overwrites the destination prompt, so a concurrent repo - // change that carried content first must win. - !composerDraftHasUserContent(getComposerDraft(destinationDraftId)) && - composerDraftHasUserContent(getComposerDraft(carryContentSourceDraftId)) - ) { - moveComposerPromptAndImages(carryContentSourceDraftId, destinationDraftId); - } - }; const project = projects.find( (candidate) => candidate.id === projectRef.projectId && candidate.environmentId === projectRef.environmentId, ); + const resolveModelSelectionOverride = (destinationDraftId: DraftId) => + resolveNewThreadModelSelectionOverride({ + projectDefaultSelection: project?.defaultModelSelection ?? null, + carrySelection: carryModelSelection, + carrySourceDraftId: + currentRouteTarget?.kind === "draft" ? currentRouteTarget.draftId : null, + destinationDraftId, + }); // The shared resolver owns the priority order. The t3.json read is // skipped entirely when a higher-priority source decides, and its // query atom caches per project after the first call. @@ -229,8 +210,10 @@ export function useNewThreadHandler() { // env context resets to the configured defaults so drafts seeded // before a defaults change (or by the old carry-over behavior) stop // landing on "current checkout" branches forever. When the draft is - // already open and no options were passed, leave it alone entirely — - // the user may have just picked a branch in the composer. + // already open and no options were passed, leave its workspace + // context alone entirely — the user may have just picked a branch + // in the composer. Model selection has its own explicit-pick rule + // below and does not follow this guard. let workspaceContext: NewThreadWorkspaceOptions | null = null; if (hasExplicitWorkspaceOption) { workspaceContext = pickExplicitWorkspaceOptions(options); @@ -276,11 +259,25 @@ export function useNewThreadHandler() { ...(carryRuntimeMode ? { runtimeMode: carryRuntimeMode } : {}), ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}), }); - if (carryModelSelection) { - // The carried selection is a complete snapshot of the viewed - // thread's model state: absent options mean "no options", not - // "keep the stale draft's options". - setModelSelection(emptyStoredDraftThread.draftId, carryModelSelection, { + } + // Model intent: an explicit human pick always stands. Seeds and + // legacy entries alike re-resolve here — sticky first, mirroring + // the mint-fresh path, then the project default or carried + // selection on top. This runs even when the draft is already open: + // without it, a changed pin could never reach the draft the user + // is looking at, because explicit picks are the only thing the + // flag protects. + const storedDraft = getComposerDraft(emptyStoredDraftThread.draftId); + const storedDraftHasExplicitModelPick = hasExplicitComposerModelSelection(storedDraft); + if (!storedDraftHasExplicitModelPick) { + applyStickyState(emptyStoredDraftThread.draftId); + const modelSelectionOverride = resolveModelSelectionOverride( + emptyStoredDraftThread.draftId, + ); + if (modelSelectionOverride) { + // This is a complete snapshot: absent options mean "no options", + // not "keep the stale draft's options". + setModelSelection(emptyStoredDraftThread.draftId, modelSelectionOverride, { replaceOptions: true, }); } @@ -300,7 +297,6 @@ export function useNewThreadHandler() { ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}), }, ); - carryComposerContentTo(emptyStoredDraftThread.draftId); const opened = { draftId: emptyStoredDraftThread.draftId, threadId: emptyStoredDraftThread.threadId, @@ -388,7 +384,6 @@ export function useNewThreadHandler() { interactionMode: racedDraft.interactionMode, ...pickExplicitWorkspaceOptions(options), }); - carryComposerContentTo(racedDraft.draftId); await router.navigate({ to: "/draft/$draftId", params: { draftId: racedDraft.draftId }, @@ -412,16 +407,12 @@ export function useNewThreadHandler() { ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}), }); applyStickyState(draftId); - if (carryModelSelection) { - // After sticky state so the viewed thread's exact selection - // (model + options like effort and context window) wins over the - // globally sticky one. replaceOptions: the carried selection is a - // complete snapshot — absent options mean "no options", not "keep - // whatever sticky state just wrote". - setModelSelection(draftId, carryModelSelection, { replaceOptions: true }); + const modelSelectionOverride = resolveModelSelectionOverride(draftId); + if (modelSelectionOverride) { + // Project defaults and carried selections both outrank global sticky + // state. The project default wins when both are present. + setModelSelection(draftId, modelSelectionOverride, { replaceOptions: true }); } - carryComposerContentTo(draftId); - await router.navigate({ to: "/draft/$draftId", params: { draftId }, @@ -430,7 +421,7 @@ export function useNewThreadHandler() { return { draftId, threadId }; })(); }, - [getCurrentRouteTarget, primaryServerSettings, projectGroupingSettings, projects, router], + [getCurrentRouteTarget, primaryServerSettings, projectGroupingSettings, router], ); } diff --git a/apps/web/src/hooks/useNowMinute.ts b/apps/web/src/hooks/useNowMinute.ts index 1b9f77b2189b..81168e5459cc 100644 --- a/apps/web/src/hooks/useNowMinute.ts +++ b/apps/web/src/hooks/useNowMinute.ts @@ -1,10 +1,7 @@ import { useSyncExternalStore } from "react"; -/** Minute-quantized clock ("YYYY-MM-DDTHH:MM") for settled-state resolution. - One module-level timer feeds every consumer through useSyncExternalStore, - so all surfaces resolving effectiveSettled against it (sidebar partition, - composer banner) share a single value by construction and tick on UTC - minute boundaries together. */ +/** Minute-quantized UI clock ("YYYY-MM-DDTHH:MM"). One module-level timer + feeds every consumer through useSyncExternalStore. */ function currentMinute(): string { return new Date().toISOString().slice(0, 16); diff --git a/apps/web/src/hooks/useSettings.test.ts b/apps/web/src/hooks/useSettings.test.ts index b332fe13c2f1..b3009b260520 100644 --- a/apps/web/src/hooks/useSettings.test.ts +++ b/apps/web/src/hooks/useSettings.test.ts @@ -76,4 +76,22 @@ describe("mergeEnvironmentSettings", () => { expect(settings.providerInstances).toBe(serverSettings.providerInstances); expect(settings.favorites).toBe(clientSettings.favorites); }); + + it("keeps server settlement settings when legacy client data contains retired keys", () => { + const serverSettings = { + ...DEFAULT_SERVER_SETTINGS, + sidebarAutoSettleAfterDays: 14, + sidebarAutoSettleOnMerge: false, + }; + const legacyClientSettings = { + ...DEFAULT_CLIENT_SETTINGS, + sidebarAutoSettleAfterDays: 1, + sidebarAutoSettleOnMerge: true, + }; + + const settings = mergeEnvironmentSettings(serverSettings, legacyClientSettings); + + expect(settings.sidebarAutoSettleAfterDays).toBe(14); + expect(settings.sidebarAutoSettleOnMerge).toBe(false); + }); }); diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index 5e633a5ded59..928b597f41d0 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -216,7 +216,9 @@ export function mergeEnvironmentSettings( serverSettings: ServerSettings, clientSettings: ClientSettings, ): UnifiedSettings { - return { ...serverSettings, ...clientSettings }; + // Decode drops retired client keys, but older untyped persistence adapters + // can still return them. Server-owned values must always win. + return { ...clientSettings, ...serverSettings }; } function useMergedSettings( @@ -357,19 +359,3 @@ export function useUpdateClientSettings() { }); }, []); } - -export function __resetClientSettingsPersistenceForTests(): void { - clientSettingsHydrationGeneration += 1; - clientSettingsSnapshot = DEFAULT_CLIENT_SETTINGS; - clientSettingsHydrated = false; - clientSettingsHydrationPromise = null; - clientSettingsListeners.clear(); - clientSettingsHydrationListeners.clear(); -} - -export function __setClientSettingsForTests(settings: ClientSettings): void { - clientSettingsHydrationGeneration += 1; - clientSettingsSnapshot = settings; - clientSettingsHydrated = true; - clientSettingsHydrationPromise = null; -} diff --git a/apps/web/src/hooks/useTheme.ts b/apps/web/src/hooks/useTheme.ts index 8696e41b71d1..e729335f96ba 100644 --- a/apps/web/src/hooks/useTheme.ts +++ b/apps/web/src/hooks/useTheme.ts @@ -13,6 +13,7 @@ import { resolveDesktopTheme, resolveThemeAppearance, resolveThemeHalf, + THEME_PREVIEW_ID, THEME_APPEARANCE_MODE_STORAGE_KEY, THEME_FOLLOW_SYSTEM_STORAGE_KEY, THEME_HALVES_STORAGE_KEY, @@ -25,6 +26,7 @@ import { type Theme = ThemePreference; type ThemeSnapshot = { theme: Theme; + resolvedTheme: ThemeAppearance; systemDark: boolean; followSystem: boolean; appearanceMode: ThemePreferenceMode; @@ -37,6 +39,7 @@ const STORAGE_KEY = "t3code:theme"; const MEDIA_QUERY = "(prefers-color-scheme: dark)"; const DEFAULT_THEME_SNAPSHOT: ThemeSnapshot = { theme: "system", + resolvedTheme: "light", systemDark: false, followSystem: true, appearanceMode: "system", @@ -49,6 +52,17 @@ export function readThemeHalves(): ThemeHalves | null { return readStoredThemeHalves(); } +/** + * The stored mix as written, without resolvability pruning. Flows that + * rebuild the whole mix must capture this before a `setTheme` clears it: a + * published id resolves only once its set has streamed in, and treating "not + * resolvable yet" as "absent" silently rewrites that half. + */ +export function readThemeHalvesRaw(): { light?: string; dark?: string } { + if (typeof window === "undefined") return {}; + return readStoredThemeHalvesRaw(); +} + function readStoredThemeHalves(): ThemeHalves | null { if (typeof window === "undefined") return null; try { @@ -58,6 +72,29 @@ function readStoredThemeHalves(): ThemeHalves | null { } } +/** + * The stored mix as written, without resolvability pruning. An environment + * published id resolves only once its set has streamed in, so a write that + * merged over the pruned parse would silently erase that half whenever the + * other one changed before the set arrived. + */ +function readStoredThemeHalvesRaw(): { light?: string; dark?: string } { + try { + const value: unknown = JSON.parse( + window.localStorage.getItem(THEME_HALVES_STORAGE_KEY) ?? "null", + ); + if (value === null || typeof value !== "object") return {}; + const halves: { light?: string; dark?: string } = {}; + for (const appearance of ["light", "dark"] as const) { + const themeId = (value as Record)[appearance]; + if (typeof themeId === "string") halves[appearance] = themeId; + } + return halves; + } catch { + return {}; + } +} + function themeHalvesSignature(halves: ThemeHalves | null): string { return `${halves?.light ?? ""}|${halves?.dark ?? ""}`; } @@ -98,7 +135,7 @@ let listeners: Array<() => void> = []; let lastSnapshot: ThemeSnapshot | null = null; let snapshotStale = true; let lastDesktopTheme: "light" | "dark" | "system" | null = null; -let lastAppliedTheme: ThemeSnapshot | null = null; +let lastAppliedTheme: Omit | null = null; let themeStorageReadFailure: ThemeStorageError | null = null; function emitChange() { @@ -282,8 +319,10 @@ export function syncBrowserChromeTheme() { } } -function applyTheme(theme: Theme, suppressTransitions = false) { +function applyTheme(theme: Theme, { suppressTransitions = false, preservePreview = true } = {}) { if (typeof document === "undefined" || typeof window === "undefined") return; + // Keep the editor's draft visible until an explicit refresh restores the selection. + if (preservePreview && document.documentElement.dataset?.themeId === THEME_PREVIEW_ID) return; const appearanceMode = readAppearanceModePreference(theme); const followSystem = appearanceMode === "system"; const systemDark = followSystem ? getSystemDark() : false; @@ -386,9 +425,17 @@ function getSnapshot(): ThemeSnapshot { const systemDark = followSystem ? getSystemDark() : false; const themeHalves = readStoredThemeHalves(); + const resolvedTheme = resolveThemeAppearance( + theme, + systemDark, + followSystem, + appearanceMode, + themeHalves, + ); if ( lastSnapshot && lastSnapshot.theme === theme && + lastSnapshot.resolvedTheme === resolvedTheme && lastSnapshot.systemDark === systemDark && lastSnapshot.followSystem === followSystem && lastSnapshot.appearanceMode === appearanceMode && @@ -397,7 +444,7 @@ function getSnapshot(): ThemeSnapshot { return lastSnapshot; } - lastSnapshot = { theme, systemDark, followSystem, appearanceMode, themeHalves }; + lastSnapshot = { theme, resolvedTheme, systemDark, followSystem, appearanceMode, themeHalves }; return lastSnapshot; } @@ -407,26 +454,28 @@ function getServerSnapshot() { function handleSystemAppearanceChange() { const storedTheme = getStored(); - if (readAppearanceModePreference(storedTheme) === "system") applyTheme(storedTheme, true); + if (readAppearanceModePreference(storedTheme) === "system") { + applyTheme(storedTheme, { suppressTransitions: true }); + } emitChange(); } function handleStorageChange(e: StorageEvent) { if (e.key === STORAGE_KEY) { themeStorageReadFailure = null; - applyTheme(getStored(), true); + applyTheme(getStored(), { suppressTransitions: true }); emitChange(); } else if (e.key === THEME_FOLLOW_SYSTEM_STORAGE_KEY) { - applyTheme(getStored(), true); + applyTheme(getStored(), { suppressTransitions: true }); emitChange(); } else if (e.key === THEME_APPEARANCE_MODE_STORAGE_KEY || e.key === THEME_HALVES_STORAGE_KEY) { - applyTheme(getStored(), true); + applyTheme(getStored(), { suppressTransitions: true }); emitChange(); } else if (e.key === CUSTOM_THEMES_STORAGE_KEY || e.key === null) { if (e.key === null) themeStorageReadFailure = null; invalidateCustomThemes(); lastAppliedTheme = null; - applyTheme(getStored(), true); + applyTheme(getStored(), { suppressTransitions: true }); emitChange(); } } @@ -460,15 +509,7 @@ function subscribe(listener: () => void): () => void { export function useTheme() { const snapshot = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); - const theme = snapshot.theme; - - const resolvedTheme: "light" | "dark" = resolveThemeAppearance( - theme, - snapshot.systemDark, - snapshot.followSystem, - snapshot.appearanceMode, - snapshot.themeHalves, - ); + const { theme, resolvedTheme } = snapshot; const setTheme = useCallback((next: Theme): boolean => { if (typeof window === "undefined") return false; @@ -511,7 +552,7 @@ export function useTheme() { }); return false; } - applyTheme(next, true); + applyTheme(next, { suppressTransitions: true }); emitChange(); return true; }, []); @@ -536,7 +577,7 @@ export function useTheme() { return false; } themeStorageReadFailure = null; - applyTheme(getStored(), true); + applyTheme(getStored(), { suppressTransitions: true }); emitChange(); return true; }, []); @@ -558,7 +599,7 @@ export function useTheme() { (appearance: ThemeAppearance, themeId: string | null): boolean => { if (typeof window === "undefined") return false; try { - const current = readStoredThemeHalves() ?? {}; + const current = readStoredThemeHalvesRaw(); const next: { light?: string; dark?: string } = { ...current }; if (themeId === null) delete next[appearance]; else next[appearance] = themeId; @@ -580,7 +621,7 @@ export function useTheme() { }); return false; } - applyTheme(getStored(), true); + applyTheme(getStored(), { suppressTransitions: true }); emitChange(); return true; }, @@ -604,15 +645,15 @@ export function useTheme() { }); return false; } - applyTheme(getStored(), true); + applyTheme(getStored(), { suppressTransitions: true }); emitChange(); return true; }, []); - const refreshTheme = useCallback(() => { + const refreshTheme = useCallback(({ preservePreview = false } = {}) => { if (typeof window === "undefined") return; lastAppliedTheme = null; - applyTheme(getStored(), true); + applyTheme(getStored(), { suppressTransitions: true, preservePreview }); emitChange(); }, []); diff --git a/apps/web/src/hooks/useThreadActionMenu.ts b/apps/web/src/hooks/useThreadActionMenu.ts index 4024fb6b7b7b..a66ea21b9891 100644 --- a/apps/web/src/hooks/useThreadActionMenu.ts +++ b/apps/web/src/hooks/useThreadActionMenu.ts @@ -5,14 +5,10 @@ import { settlePromise, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; -import { - canSnooze, - effectiveSettled, - effectiveSnoozed, - type ChangeRequestSettleSource, -} from "@t3tools/client-runtime/state/thread-settled"; +import { canSnooze, effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled"; import type { ScopedThreadRef, ThreadId } from "@t3tools/contracts"; -import { useCallback } from "react"; +import { useRouter } from "@tanstack/react-router"; +import { useCallback, useMemo } from "react"; import { resolveSnoozePresets, snoozeWakeDescription } from "../components/Sidebar.snooze"; import { @@ -28,8 +24,16 @@ import { readEnvironmentSupportsSnooze, readEnvironmentSupportsTitleRegeneration, readThreadShell, + useProjects, } from "../state/entities"; +import { usePrimaryEnvironmentId } from "../state/environments"; import { readLocalApi } from "../localApi"; +import { + deriveLogicalProjectKeyFromSettings, + derivePhysicalProjectKey, + selectProjectGroupingSettings, +} from "../logicalProject"; +import { buildPhysicalToLogicalProjectKeyMap } from "../sidebarProjectGrouping"; import { useUiStateStore } from "../uiStateStore"; import { useCopyToClipboard } from "./useCopyToClipboard"; import { useNewThreadHandler } from "./useHandleNewThread"; @@ -60,18 +64,29 @@ export function useThreadActionMenu(input: { readonly threadRef: ScopedThreadRef | null; /** Fallback for "Copy path" when the thread has no worktree. */ readonly projectCwd: string | null; - /** PR feeding auto-settle classification, as resolved by the caller. */ - readonly changeRequest: ChangeRequestSettleSource | null; readonly onStartRename: () => void; }) { - const { threadRef, projectCwd, changeRequest, onStartRename } = input; + const { threadRef, projectCwd, onStartRename } = input; + const router = useRouter(); + const projects = useProjects(); + const primaryEnvironmentId = usePrimaryEnvironmentId(); + const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); + const logicalProjectKeyByPhysicalKey = useMemo( + () => + buildPhysicalToLogicalProjectKeyMap({ + projects, + settings: projectGroupingSettings, + primaryEnvironmentId, + }), + [primaryEnvironmentId, projectGroupingSettings, projects], + ); const { settleThread, unsettleThread, snoozeThread, unsnoozeThread, pinThread, - unpinThread, + confirmAndUnpinThread, archiveThread, deleteThread, } = useThreadActions(); @@ -80,8 +95,6 @@ export function useThreadActionMenu(input: { }); const handleNewThread = useNewThreadHandler(); const markThreadUnread = useUiStateStore((s) => s.markThreadUnread); - const autoSettleAfterDays = useClientSettings((s) => s.sidebarAutoSettleAfterDays); - const autoSettleOnMerge = useClientSettings((s) => s.sidebarAutoSettleOnMerge); const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete); const confirmThreadArchive = useClientSettings((s) => s.confirmThreadArchive); const timestampFormat = useClientSettings((s) => s.timestampFormat); @@ -127,17 +140,7 @@ export function useThreadActionMenu(input: { const items = buildThreadActionMenuItems({ branch: thread.branch ?? null, isPinned: thread.pinnedAt != null, - isSettled: - supports.settlement && - effectiveSettled(thread, { - // Minute-quantized like useNowMinute, so this classification - // can never disagree with the sidebar partition or ChatView's - // parked-thread banner within the same minute. - now: `${now.toISOString().slice(0, 16)}:00.000Z`, - autoSettleAfterDays, - autoSettleOnMerge, - changeRequest, - }), + isSettled: supports.settlement && thread.settledOverride === "settled", isSnoozed: supports.snooze && effectiveSnoozed(thread, { now: now.toISOString() }), canSnoozeNow: canSnooze(thread, { now: now.toISOString() }), isRegeneratingTitle, @@ -187,6 +190,22 @@ export function useThreadActionMenu(input: { } }; switch (action) { + case "project-settings": { + const project = projects.find( + (candidate) => + candidate.environmentId === thread.environmentId && + candidate.id === thread.projectId, + ); + if (!project) return; + const projectKey = + logicalProjectKeyByPhysicalKey.get(derivePhysicalProjectKey(project)) ?? + deriveLogicalProjectKeyFromSettings(project, projectGroupingSettings); + void router.navigate({ + to: "/projects/$projectKey", + params: { projectKey }, + }); + return; + } case "new-thread-on-branch": { // Explicit branch carry-over: reuse the thread's worktree when it // has one, otherwise its branch on the local checkout. @@ -215,9 +234,10 @@ export function useThreadActionMenu(input: { case "pin": await reportFailure("Failed to pin thread", () => pinThread(threadRef)); return; - case "unpin": - await reportFailure("Failed to unpin thread", () => unpinThread(threadRef)); + case "unpin": { + await reportFailure("Failed to unpin thread", () => confirmAndUnpinThread(threadRef)); return; + } case "rename": onStartRename(); return; @@ -310,25 +330,26 @@ export function useThreadActionMenu(input: { }, [ archiveThread, - autoSettleAfterDays, - autoSettleOnMerge, - changeRequest, confirmThreadArchive, confirmThreadDelete, + confirmAndUnpinThread, copyBranchToClipboard, copyPathToClipboard, copyThreadIdToClipboard, deleteThread, handleNewThread, + logicalProjectKeyByPhysicalKey, markThreadUnread, onStartRename, pinThread, projectCwd, + projectGroupingSettings, + projects, + router, settleThread, snoozeThread, threadRef, timestampFormat, - unpinThread, unsettleThread, unsnoozeThread, updateThreadMetadata, diff --git a/apps/web/src/hooks/useThreadActions.test.ts b/apps/web/src/hooks/useThreadActions.test.ts index c5385211591f..e2a8b6d1b4b1 100644 --- a/apps/web/src/hooks/useThreadActions.test.ts +++ b/apps/web/src/hooks/useThreadActions.test.ts @@ -1,7 +1,7 @@ import { EnvironmentId, ThreadId } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; -import { ThreadArchiveBlockedError } from "./useThreadActions"; +import { requestThreadUnpinConfirmation, ThreadArchiveBlockedError } from "./useThreadActions"; describe("ThreadArchiveBlockedError", () => { it("keeps the blocked thread context with the fixed message", () => { @@ -17,3 +17,57 @@ describe("ThreadArchiveBlockedError", () => { expect(error.message).toBe("Cannot archive a running thread."); }); }); + +describe("requestThreadUnpinConfirmation", () => { + it("skips the dialog when confirmation is disabled", async () => { + let callCount = 0; + const result = await requestThreadUnpinConfirmation({ + enabled: false, + title: "Pinned thread", + confirm: async () => { + callCount += 1; + return false; + }, + }); + + expect(result).toMatchObject({ _tag: "Success", value: true }); + expect(callCount).toBe(0); + }); + + it("degrades gracefully when dialogs are unavailable", async () => { + const result = await requestThreadUnpinConfirmation({ + enabled: true, + title: "Pinned thread", + confirm: null, + }); + + expect(result).toMatchObject({ _tag: "Success", value: true }); + }); + + it("uses the thread title and returns the user's decision", async () => { + let message = ""; + const result = await requestThreadUnpinConfirmation({ + enabled: true, + title: "Release prep", + confirm: async (nextMessage) => { + message = nextMessage; + return false; + }, + }); + + expect(message).toBe( + 'Unpin thread "Release prep"?\nThis will move the thread out of your pinned section.', + ); + expect(result).toMatchObject({ _tag: "Success", value: false }); + }); + + it("keeps dialog failures observable", async () => { + const result = await requestThreadUnpinConfirmation({ + enabled: true, + title: "Pinned thread", + confirm: () => Promise.reject(new Error("dialog unavailable")), + }); + + expect(result._tag).toBe("Failure"); + }); +}); diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index f40920779b0f..64915228c779 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -5,7 +5,7 @@ import { scopedThreadKey, } from "@t3tools/client-runtime/environment"; import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; -import { canSettle, canSnooze, threadWokeAt } from "@t3tools/client-runtime/state/thread-settled"; +import { canSnooze, threadWokeAt } from "@t3tools/client-runtime/state/thread-settled"; import { EnvironmentId, type ScopedThreadRef, ThreadId } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Schema from "effect/Schema"; @@ -64,18 +64,6 @@ export class ThreadSettlementUnsupportedError extends Schema.TaggedErrorClass()( - "ThreadSettleBlockedError", - { - environmentId: EnvironmentId, - threadId: ThreadId, - }, -) { - override get message(): string { - return "This thread still needs attention. Resolve or interrupt it first, then try again."; - } -} - export class ThreadSnoozeUnsupportedError extends Schema.TaggedErrorClass()( "ThreadSnoozeUnsupportedError", { @@ -136,6 +124,26 @@ export class ThreadPinReorderUnsupportedError extends Schema.TaggedErrorClass Promise) | null; +}) { + const { confirm } = input; + if (!input.enabled || confirm === null) { + return AsyncResult.success(true); + } + + return settlePromise(() => + confirm( + [ + `Unpin thread "${input.title}"?`, + "This will move the thread out of your pinned section.", + ].join("\n"), + ), + ); +} + export function useThreadActions() { const closeTerminal = useAtomCommand(terminalEnvironment.close); const archiveThreadMutation = useAtomCommand(threadEnvironment.archive, { @@ -177,6 +185,7 @@ export function useThreadActions() { }); const sidebarThreadSortOrder = useClientSettings((settings) => settings.sidebarThreadSortOrder); const confirmThreadDelete = useClientSettings((settings) => settings.confirmThreadDelete); + const confirmThreadUnpin = useClientSettings((settings) => settings.confirmThreadUnpin); const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearDraftThread); const clearProjectDraftThreadById = useComposerDraftStore( (store) => store.clearProjectDraftThreadById, @@ -485,19 +494,6 @@ export function useThreadActions() { ); } const resolved = resolveThreadTarget(target); - // Settle may only target what effectiveSettled could classify as - // settled: not starting/running sessions, not threads waiting on - // approvals or user input. Anything else would hide live work. - if (resolved && !canSettle(resolved.thread, { now: new Date().toISOString() })) { - return AsyncResult.failure( - Cause.fail( - new ThreadSettleBlockedError({ - environmentId: resolved.threadRef.environmentId, - threadId: resolved.threadRef.threadId, - }), - ), - ); - } const wokeAt = resolved ? threadWokeAt(resolved.thread, { now: new Date().toISOString() }) : null; @@ -590,6 +586,26 @@ export function useThreadActions() { [unpinThreadMutation], ); + const confirmAndUnpinThread = useCallback( + async (target: ScopedThreadRef) => { + const localApi = readLocalApi(); + const resolved = resolveThreadTarget(target); + const confirmationResult = await requestThreadUnpinConfirmation({ + enabled: confirmThreadUnpin, + title: resolved?.thread.title ?? "this thread", + confirm: localApi ? (message) => localApi.dialogs.confirm(message) : null, + }); + if (confirmationResult._tag === "Failure") { + return confirmationResult; + } + if (!confirmationResult.value) { + return AsyncResult.success(undefined); + } + return unpinThread(target); + }, + [confirmThreadUnpin, resolveThreadTarget, unpinThread], + ); + const reorderPinnedThread = useCallback( async (target: ScopedThreadRef, orderKey: string) => { // Callers (the sidebar drag handler) only enable dragging on @@ -709,11 +725,13 @@ export function useThreadActions() { unsnoozeThread, pinThread, unpinThread, + confirmAndUnpinThread, reorderPinnedThread, }), [ archiveThread, confirmAndDeleteThread, + confirmAndUnpinThread, deleteThread, pinThread, reorderPinnedThread, diff --git a/apps/web/src/hooks/useWorkspaceMutationRefresh.test.ts b/apps/web/src/hooks/useWorkspaceMutationRefresh.test.ts new file mode 100644 index 000000000000..28cc0d0e40a8 --- /dev/null +++ b/apps/web/src/hooks/useWorkspaceMutationRefresh.test.ts @@ -0,0 +1,62 @@ +import { EventId, type OrchestrationThreadActivity } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + latestWorkspaceMutationId, + workspaceMutationRefreshToken, +} from "./useWorkspaceMutationRefresh"; + +function activity( + id: string, + kind: string, + itemType: string, + status?: string, +): OrchestrationThreadActivity { + return { + id: EventId.make(id), + kind, + tone: "tool", + summary: "Tool activity", + payload: { itemType, ...(status ? { status } : {}) }, + turnId: null, + createdAt: "2026-08-30T00:00:00.000Z", + }; +} + +describe("workspace mutation refresh", () => { + it("tracks the latest completed file change or command", () => { + expect( + latestWorkspaceMutationId([ + activity("file-started", "tool.started", "file_change"), + activity("search-completed", "tool.completed", "web_search"), + activity("file-completed", "tool.completed", "file_change"), + activity("command-completed", "tool.completed", "command_execution"), + ]), + ).toBe("command-completed"); + }); + + it("ignores read-only and in-progress tools", () => { + expect( + latestWorkspaceMutationId([ + activity("command-updated", "tool.updated", "command_execution", "inProgress"), + activity("legacy-command-updated", "tool.updated", "command_execution", "in_progress"), + activity("image-completed", "tool.completed", "image_view"), + ]), + ).toBeNull(); + }); + + it("accepts providers that report terminal state on an update", () => { + expect( + latestWorkspaceMutationId([ + activity("file-updated", "tool.updated", "file_change", "completed"), + ]), + ).toBe("file-updated"); + }); + + it("scopes the same mutation to each preview resource", () => { + expect(workspaceMutationRefreshToken("file:/repo/README.md", "event-1")).not.toBe( + workspaceMutationRefreshToken("diff:/repo", "event-1"), + ); + expect(workspaceMutationRefreshToken("file:/repo/README.md", null)).toBeNull(); + }); +}); diff --git a/apps/web/src/hooks/useWorkspaceMutationRefresh.ts b/apps/web/src/hooks/useWorkspaceMutationRefresh.ts new file mode 100644 index 000000000000..a4dc0733bf55 --- /dev/null +++ b/apps/web/src/hooks/useWorkspaceMutationRefresh.ts @@ -0,0 +1,65 @@ +import type { OrchestrationThreadActivity } from "@t3tools/contracts"; +import { useEffect, useRef } from "react"; + +const WORKSPACE_MUTATION_ITEM_TYPES = new Set(["command_execution", "file_change"]); + +function activityPayload(activity: OrchestrationThreadActivity): Record | null { + return activity.payload !== null && typeof activity.payload === "object" + ? (activity.payload as Record) + : null; +} + +/** + * The latest provider event after which files on disk may have changed. + * File tools are explicit; completed commands are included because a shell + * command can mutate the workspace without reporting the paths it touched. + */ +export function latestWorkspaceMutationId( + activities: ReadonlyArray, +): string | null { + for (let index = activities.length - 1; index >= 0; index -= 1) { + const activity = activities[index]; + if (!activity) continue; + const payload = activityPayload(activity); + const terminalUpdate = + activity.kind === "tool.updated" && + typeof payload?.status === "string" && + payload.status !== "inProgress" && + payload.status !== "in_progress"; + if (activity.kind !== "tool.completed" && !terminalUpdate) continue; + const itemType = payload?.itemType; + if (typeof itemType === "string" && WORKSPACE_MUTATION_ITEM_TYPES.has(itemType)) { + return activity.id; + } + } + return null; +} + +export function workspaceMutationRefreshToken( + resourceKey: string, + mutationId: string | null, +): string | null { + return mutationId === null ? null : `${resourceKey}\u0000${mutationId}`; +} + +/** + * Refreshes once per mutation and resource. Disabled mutations stay pending, + * which lets an editable file catch up after its local save finishes. + */ +export function useWorkspaceMutationRefresh(input: { + readonly enabled?: boolean; + readonly mutationId: string | null; + readonly refresh: () => void; + readonly resourceKey: string; +}): void { + const { enabled = true, mutationId, refresh, resourceKey } = input; + const handledTokenRef = useRef(null); + + useEffect(() => { + if (!enabled) return; + const token = workspaceMutationRefreshToken(resourceKey, mutationId); + if (token === null || token === handledTokenRef.current) return; + handledTokenRef.current = token; + refresh(); + }, [enabled, mutationId, refresh, resourceKey]); +} diff --git a/apps/web/src/index.css b/apps/web/src/index.css index f69adb9cf08e..22396d4f8aea 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -111,6 +111,7 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil --workspace-native-controls-inset: 0px; --workspace-titlebar-control-size: 1.75rem; --workspace-titlebar-control-gap: 0.75rem; + --workspace-titlebar-scroll-fade-height: 1.5rem; @variant dark { --appearance-contrast-target: white; @@ -205,7 +206,6 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil --radius-xl: calc(var(--radius) + 4px); --radius-2xl: calc(var(--radius) + 8px); --radius-3xl: calc(var(--radius) + 12px); - --radius-4xl: calc(var(--radius) + 16px); @keyframes skeleton { /* Transform-only so the highlight sweep stays on the compositor, then a long hold with the band parked off-screen instead of a constant shimmer. */ @@ -354,7 +354,6 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil } @utility topbar-scroll-fade { - --topbar-scroll-fade-height: 2.5rem; -webkit-mask-image: linear-gradient( to bottom, @@ -370,8 +369,8 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil -webkit-mask-position: top, bottom, right; -webkit-mask-repeat: no-repeat; -webkit-mask-size: - 100% var(--topbar-scroll-fade-height), - 100% calc(100% - var(--topbar-scroll-fade-height)), + 100% var(--workspace-titlebar-scroll-fade-height), + 100% calc(100% - var(--workspace-titlebar-scroll-fade-height)), var(--app-scrollbar-width) 100%; mask-image: linear-gradient( @@ -388,13 +387,9 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil mask-position: top, bottom, right; mask-repeat: no-repeat; mask-size: - 100% var(--topbar-scroll-fade-height), - 100% calc(100% - var(--topbar-scroll-fade-height)), + 100% var(--workspace-titlebar-scroll-fade-height), + 100% calc(100% - var(--workspace-titlebar-scroll-fade-height)), var(--app-scrollbar-width) 100%; - - @variant sm { - --topbar-scroll-fade-height: 3rem; - } } /* Virtualizers own their native scroll element, so they cannot use ScrollArea's @@ -763,472 +758,6 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil outline: none; } - .chat-composer-shoulder-tab { - border-color: var( - --chat-composer-outline, - color-mix(in srgb, var(--contrast-foreground) 8%, transparent) - ); - background: color-mix( - in srgb, - var(--chat-composer-glass-surface, var(--card)) var(--glass-opacity), - transparent - ); - -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation)); - backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation)); - box-shadow: 0 -8px 24px -18px rgb(0 0 0 / 45%); - - @variant dark { - box-shadow: 0 -10px 28px -18px rgb(0 0 0 / 80%); - } - - @supports not ((-webkit-backdrop-filter: blur(1px)) or (backdrop-filter: blur(1px))) { - background: var(--chat-composer-glass-surface, var(--card)); - } - } - - .chat-composer-banner-stack-cap { - --chat-composer-attached-surface: var(--chat-composer-glass-surface, var(--card)); - --chat-composer-attached-outline: var( - --chat-composer-outline, - color-mix(in srgb, var(--contrast-foreground) 8%, transparent) - ); - background: color-mix( - in srgb, - var(--chat-composer-attached-surface) var(--glass-opacity), - transparent - ); - -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation)); - backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation)); - - @variant dark { - --chat-composer-attached-surface: var( - --chat-composer-glass-surface, - color-mix(in srgb, var(--background) 96%, var(--color-white)) - ); - --chat-composer-attached-outline: var( - --chat-composer-outline, - color-mix(in srgb, var(--color-white) 5%, transparent) - ); - } - - @supports not ((-webkit-backdrop-filter: blur(1px)) or (backdrop-filter: blur(1px))) { - background: var(--chat-composer-attached-surface); - } - } - - /* Mirror the run-context strip above the composer. These surfaces deliberately - own no card fill or border themselves: a masked pseudo-element draws the - same inset glass, 16px outer corners, quiet outline, and open attachment - seam as the strip below. */ - .chat-composer-drawer-surface, - .chat-composer-top-drawer { - --chat-composer-attachment-overlap: calc(1rem + 1px); - --chat-composer-attached-surface: var(--chat-composer-glass-surface, var(--card)); - --chat-composer-attached-outline: var( - --chat-composer-outline, - color-mix(in srgb, var(--contrast-foreground) 8%, transparent) - ); - --chat-composer-attached-tint: transparent; - position: relative; - isolation: isolate; - border: 0 !important; - background: transparent !important; - box-shadow: none; - - @variant dark { - --chat-composer-attached-surface: var( - --chat-composer-glass-surface, - color-mix(in srgb, var(--background) 96%, var(--color-white)) - ); - --chat-composer-attached-outline: var( - --chat-composer-outline, - color-mix(in srgb, var(--color-white) 5%, transparent) - ); - - &::before { - background: - linear-gradient(var(--chat-composer-attached-tint), var(--chat-composer-attached-tint)), - linear-gradient( - to top, - transparent 0 var(--chat-composer-attachment-overlap), - rgb(0 0 0 / 18%) var(--chat-composer-attachment-overlap), - transparent calc(var(--chat-composer-attachment-overlap) + 10px) - ), - color-mix( - in srgb, - var(--chat-composer-attached-surface) var(--glass-opacity), - transparent - ); - box-shadow: 0 14px 32px -18px rgb(0 0 0 / 75%); - } - } - } - - .chat-composer-drawer-attached, - .chat-composer-top-drawer { - padding-bottom: var(--chat-composer-attachment-overlap); - } - - [data-chat-composer-form="true"], - .chat-composer-drawer-slot, - .chat-composer-top-drawer { - --chat-composer-drawer-inset: 1.375rem; - } - - .chat-composer-drawer-slot, - .chat-composer-top-drawer { - --chat-composer-attachment-overlap: calc(1rem + 1px); - width: calc(100% - var(--chat-composer-drawer-inset) - var(--chat-composer-drawer-inset)); - max-width: calc(48rem - var(--chat-composer-drawer-inset) - var(--chat-composer-drawer-inset)); - margin-inline: auto; - margin-bottom: calc(-1 * var(--chat-composer-attachment-overlap)); - } - - :is(.chat-composer-drawer-surface, .chat-composer-top-drawer)::before { - pointer-events: none; - position: absolute; - z-index: -1; - inset: 0; - border: 1px solid var(--chat-composer-attached-outline); - border-radius: 16px 16px 0 0; - background: - linear-gradient(var(--chat-composer-attached-tint), var(--chat-composer-attached-tint)), - color-mix(in srgb, var(--chat-composer-attached-surface) var(--glass-opacity), transparent); - -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation)); - backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation)); - -webkit-mask-image: linear-gradient( - to top, - transparent 0 var(--chat-composer-attachment-overlap), - black var(--chat-composer-attachment-overlap) - ); - mask-image: linear-gradient( - to top, - transparent 0 var(--chat-composer-attachment-overlap), - black var(--chat-composer-attachment-overlap) - ); - box-shadow: 0 12px 28px -18px rgb(0 0 0 / 40%); - content: ""; - - @supports not ((-webkit-backdrop-filter: blur(1px)) or (backdrop-filter: blur(1px))) { - background: - linear-gradient(var(--chat-composer-attached-tint), var(--chat-composer-attached-tint)), - var(--chat-composer-attached-surface) !important; - } - } - - .chat-composer-drawer-slot + .chat-composer-drawer-slot::before, - .chat-composer-drawer-slot + :has(.chat-composer-top-drawer) .chat-composer-top-drawer::before { - border-top: 0; - border-radius: 0; - } - - .chat-composer-top-drawer { - z-index: 0; - } - - :is(.chat-composer-drawer-surface, .chat-composer-top-drawer)[data-variant="error"] { - --chat-composer-attached-outline: color-mix(in srgb, var(--error) 32%, transparent); - --chat-composer-attached-tint: color-mix(in srgb, var(--error) 8%, transparent); - } - - :is(.chat-composer-drawer-surface, .chat-composer-top-drawer)[data-variant="info"] { - --chat-composer-attached-outline: color-mix(in srgb, var(--info) 32%, transparent); - --chat-composer-attached-tint: color-mix(in srgb, var(--info) 4%, transparent); - } - - :is(.chat-composer-drawer-surface, .chat-composer-top-drawer)[data-variant="success"] { - --chat-composer-attached-outline: color-mix(in srgb, var(--success) 32%, transparent); - --chat-composer-attached-tint: color-mix(in srgb, var(--success) 4%, transparent); - } - - :is(.chat-composer-drawer-surface, .chat-composer-top-drawer)[data-variant="warning"] { - --chat-composer-attached-outline: color-mix(in srgb, var(--warning) 28%, transparent); - --chat-composer-attached-tint: color-mix(in srgb, var(--warning) 8%, transparent); - } - - .chat-composer-glass-shell { - --chat-composer-attachment-overlap: calc(1rem + 1px); - --chat-composer-glass-surface: var(--card); - --chat-composer-outline: rgb(0 0 0 / 8%); - - isolation: isolate; - - @variant dark { - --chat-composer-glass-surface: color-mix(in srgb, var(--background) 96%, var(--color-white)); - --chat-composer-outline: color-mix(in srgb, var(--color-white) 5%, transparent); - --chat-composer-highlight: rgb(255 255 255 / 3%); - } - } - - .chat-composer-glass-shell::before { - pointer-events: none; - position: absolute; - z-index: 0; - inset: 0; - border-radius: 22px; - background: color-mix( - in srgb, - var(--chat-composer-glass-surface) var(--glass-opacity), - transparent - ); - -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation)); - backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation)); - content: ""; - } - - .chat-composer-glass-shell-with-context { - --chat-composer-context-extension: 2.25rem; - } - - .chat-composer-glass-shell-with-context::before { - border-radius: 0; - /* - * One continuous glass layer: a 22px composer joined to a 16px strip. The - * strip is inset 1.375rem per side, so the step-in positions and their - * curve controls stay in rem to keep tracking it at non-default interface - * font sizes; the composer's 22px top radius and the strip's 16px bottom - * radius are px by design. - */ - clip-path: shape( - from 0 22px, - curve to 22px 0 with 0 9.85px / 9.85px 0, - line to calc(100% - 22px) 0, - curve to 100% 22px with calc(100% - 9.85px) 0 / 100% 9.85px, - line to 100% calc(100% - var(--chat-composer-context-extension) - 1.375rem), - curve to calc(100% - 1.375rem) calc(100% - var(--chat-composer-context-extension)) with 100% - calc(100% - var(--chat-composer-context-extension) - 0.6156rem) / calc(100% - 0.6156rem) - calc(100% - var(--chat-composer-context-extension)), - line to calc(100% - 1.375rem) calc(100% - 16px), - curve to calc(100% - 1.375rem - 16px) 100% with calc(100% - 1.375rem) calc(100% - 7.16px) / - calc(100% - 1.375rem - 7.16px) 100%, - line to calc(1.375rem + 16px) 100%, - curve to 1.375rem calc(100% - 16px) with calc(1.375rem + 7.16px) 100% / 1.375rem - calc(100% - 7.16px), - line to 1.375rem calc(100% - var(--chat-composer-context-extension)), - curve to 0 calc(100% - var(--chat-composer-context-extension) - 1.375rem) with 0.6156rem - calc(100% - var(--chat-composer-context-extension)) / 0 - calc(100% - var(--chat-composer-context-extension) - 0.6156rem), - line to 0 22px, - close - ); - } - - @media (min-width: 40rem) { - .chat-composer-glass-shell-with-context { - /* The xs toolbar controls shrink by 4px at Tailwind's sm breakpoint. */ - --chat-composer-context-extension: 2rem; - } - } - - .chat-composer-glass-host { - box-shadow: 0 12px 28px -18px rgb(0 0 0 / 40%); - - @variant dark { - box-shadow: none; - - &::after { - box-shadow: inset 0 1px var(--chat-composer-highlight); - } - } - } - - .chat-composer-glass-host::after { - pointer-events: none; - position: absolute; - z-index: 1; - inset: 0; - border: 1px solid var(--chat-composer-outline); - border-radius: inherit; - content: ""; - } - - /* A top attachment cannot share the shell's single clipped backdrop: it - needs the composer surface and outline to paint in front of its overlap. - Split the glass into exact surfaces while a drawer or tab is present; the - default composer keeps the cheaper continuous layer. */ - .chat-composer-glass-shell:is( - .chat-composer-glass-shell-attached, - :has(:is(.chat-composer-top-drawer, .chat-composer-shoulder-tab)) - )::before, - .chat-composer-glass-shell:is( - .chat-composer-glass-shell-attached, - :has(:is(.chat-composer-top-drawer, .chat-composer-shoulder-tab)) - ) - .chat-composer-glass-host::after { - display: none; - } - - .chat-composer-glass-shell:is( - .chat-composer-glass-shell-attached, - :has(:is(.chat-composer-top-drawer, .chat-composer-shoulder-tab)) - ) - .chat-composer-glass-host { - box-shadow: none; - } - - .chat-composer-glass-shell:is( - .chat-composer-glass-shell-attached, - :has(:is(.chat-composer-top-drawer, .chat-composer-shoulder-tab)) - ) - .chat-composer-glass-host - [data-chat-composer-main-surface="true"] { - background: color-mix( - in srgb, - var(--chat-composer-glass-surface) var(--glass-opacity), - transparent - ); - -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation)); - backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation)); - box-shadow: 0 12px 28px -18px rgb(0 0 0 / 40%); - - &::after { - pointer-events: none; - position: absolute; - z-index: 20; - inset: 0; - border: 1px solid var(--chat-composer-outline); - border-radius: inherit; - content: ""; - } - - @variant dark { - box-shadow: none; - - &::after { - box-shadow: inset 0 1px var(--chat-composer-highlight); - } - } - - @supports not ((-webkit-backdrop-filter: blur(1px)) or (backdrop-filter: blur(1px))) { - background: var(--chat-composer-glass-surface); - } - } - - .chat-composer-glass-shell:is( - .chat-composer-glass-shell-attached, - :has(.chat-composer-top-drawer) - ) - .chat-composer-glass-host - [data-chat-composer-mobile-collapsed="true"] { - min-height: var(--chat-composer-attachment-overlap); - } - - .chat-composer-glass-shell-with-context .chat-composer-glass-host::after, - .chat-composer-glass-shell-with-context:is( - .chat-composer-glass-shell-attached, - :has(:is(.chat-composer-top-drawer, .chat-composer-shoulder-tab)) - ) - [data-chat-composer-main-surface="true"]::after { - /* Keep the attachment seam open; the strip continues the outer outline below. */ - clip-path: polygon( - 0 0, - 100% 0, - 100% 100%, - calc(100% - 22px) 100%, - calc(100% - 22px) calc(100% - 2px), - 22px calc(100% - 2px), - 22px 100%, - 0 100% - ); - } - - .chat-composer-context-strip { - position: relative; - isolation: isolate; - - @variant dark { - &::before { - border-color: rgb(255 255 255 / 7%); - background: - linear-gradient( - to bottom, - transparent 0 1rem, - rgb(0 0 0 / 18%) 1rem, - transparent calc(1rem + 10px) - ), - rgb(255 255 255 / 2%); - box-shadow: 0 14px 32px -18px rgb(0 0 0 / 75%); - } - } - } - - .chat-composer-context-strip::before { - position: absolute; - z-index: -1; - inset: 0; - border: 1px solid var(--chat-composer-outline); - border-radius: 0 0 16px 16px; - /* Start the strip outline exactly where the composer's 22px curve reaches its tangent. */ - -webkit-mask-image: linear-gradient(to bottom, transparent 0 1rem, black 1rem); - mask-image: linear-gradient(to bottom, transparent 0 1rem, black 1rem); - box-shadow: 0 12px 28px -18px rgb(0 0 0 / 40%); - content: ""; - } - - .chat-composer-glass-shell:is( - .chat-composer-glass-shell-attached, - :has(:is(.chat-composer-top-drawer, .chat-composer-shoulder-tab)) - ) - .chat-composer-context-strip { - &::before { - background: color-mix( - in srgb, - var(--chat-composer-glass-surface) var(--glass-opacity), - transparent - ); - -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation)); - backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation)); - } - - @variant dark { - &::before { - background: - linear-gradient( - to bottom, - transparent 0 1rem, - rgb(0 0 0 / 18%) 1rem, - transparent calc(1rem + 10px) - ), - linear-gradient(rgb(255 255 255 / 2%), rgb(255 255 255 / 2%)), - color-mix(in srgb, var(--chat-composer-glass-surface) var(--glass-opacity), transparent); - } - } - } - - @supports not (clip-path: shape(from 0 0, line to 1px 1px)) { - .chat-composer-glass-shell-with-context::before { - inset-block-end: var(--chat-composer-context-extension); - border-radius: 22px; - clip-path: none; - } - - .chat-composer-context-strip::before { - background: color-mix( - in srgb, - var(--chat-composer-glass-surface) var(--glass-opacity), - transparent - ); - -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation)); - backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation)); - } - - .chat-composer-context-strip { - @variant dark { - &::before { - background: - linear-gradient( - to bottom, - transparent 0 1rem, - rgb(0 0 0 / 18%) 1rem, - transparent calc(1rem + 10px) - ), - linear-gradient(rgb(255 255 255 / 2%), rgb(255 255 255 / 2%)), - color-mix(in srgb, var(--chat-composer-glass-surface) var(--glass-opacity), transparent); - } - } - } - } - .settings-slider { --settings-slider-progress: 0%; --settings-slider-fill-offset: 0.5rem; @@ -1343,12 +872,6 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil all: revert; } } - - @supports not ((-webkit-backdrop-filter: blur(1px)) or (backdrop-filter: blur(1px))) { - .chat-composer-glass-shell::before { - background: var(--chat-composer-glass-surface); - } - } } /* Safe-area inset utilities for surfaces that opt into edge-to-edge rendering. @@ -1615,58 +1138,6 @@ html[data-theme-id]:not([data-theme-id=""]) { --terminal-selection-background: var(--app-theme-terminal-selection-background); } -/* T3 Chat's composer is a translucent lift over --chat-background. Route its - measured flattened color through the raised-surface role instead of deriving - another tint from the canvas, which made the dark composer too red. */ -html[data-theme-id] .chat-composer-glass-shell { - --chat-composer-glass-surface: var(--app-theme-surface-raised); - --chat-composer-outline: var(--app-theme-toolbar-border); - - @variant dark { - --chat-composer-outline: color-mix(in srgb, var(--app-theme-input) 30%, var(--background)); - --chat-composer-highlight: color-mix(in srgb, var(--app-theme-input) 12%, transparent); - } -} - -html[data-theme-id] - :is(.chat-composer-banner-stack-cap, .chat-composer-drawer-surface, .chat-composer-top-drawer) { - --chat-composer-attached-surface: var(--app-theme-surface-raised); -} - -html[data-theme-id] - :is(.chat-composer-banner-stack-cap, .chat-composer-drawer-surface, .chat-composer-top-drawer):is( - :not([data-variant]), - [data-variant="default"] - ) { - --chat-composer-attached-outline: var(--chat-composer-outline, var(--app-theme-toolbar-border)); - - @variant dark { - --chat-composer-attached-outline: var( - --chat-composer-outline, - color-mix(in srgb, var(--app-theme-input) 30%, var(--background)) - ); - } -} - -html[data-theme-id="t3-chat"] .chat-composer-glass-shell { - /* T3 Chat's visible composer edge is a dark plum, not the stock translucent - white outline. Its highlight is derived from --chat-input-gradient. */ - @variant dark { - --chat-composer-outline: #241e28; - --chat-composer-highlight: color-mix(in srgb, #432d48 12%, transparent); - } -} - -html[data-theme-id="t3-chat"] - :is(.chat-composer-banner-stack-cap, .chat-composer-drawer-surface, .chat-composer-top-drawer):is( - :not([data-variant]), - [data-variant="default"] - ) { - @variant dark { - --chat-composer-attached-outline: #241e28; - } -} - /* Theme-token dependency probes are restored synchronously, before paint. Keep transitions from observing the temporary sentinel color in between. */ html[data-theme-token-probe], @@ -2271,13 +1742,13 @@ code { font-size: 0.75rem; } -.chat-markdown a.chat-markdown-file-link, -.chat-markdown a.chat-markdown-file-link:hover { +.chat-markdown .chat-markdown-file-link, +.chat-markdown .chat-markdown-file-link:hover { color: var(--contrast-foreground); text-decoration: none; } -.chat-markdown a.chat-markdown-file-link:focus-visible { +.chat-markdown .chat-markdown-file-link:focus-visible { outline: none; box-shadow: 0 0 0 2px color-mix(in srgb, var(--ring) 70%, transparent); } diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index 7d07610c6430..0f3fa825e44b 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -142,6 +142,16 @@ const DEFAULT_BINDINGS = compile([ { shortcut: modShortcut("o"), command: "editor.openFavorite" }, { shortcut: modShortcut("[", { shiftKey: true }), command: "thread.previous" }, { shortcut: modShortcut("]", { shiftKey: true }), command: "thread.next" }, + { + shortcut: modShortcut("c", { shiftKey: true }), + command: "thread.copyReference", + whenAst: whenNot(whenIdentifier("terminalFocus")), + }, + { + shortcut: modShortcut("s", { shiftKey: true }), + command: "thread.settle", + whenAst: whenNot(whenIdentifier("terminalFocus")), + }, { shortcut: modShortcut("1"), command: "thread.jump.1" }, { shortcut: modShortcut("2"), command: "thread.jump.2" }, { shortcut: modShortcut("3"), command: "thread.jump.3" }, @@ -187,6 +197,53 @@ describe("isTerminalToggleShortcut", () => { }); }); +describe("settle thread shortcut", () => { + it("resolves outside the terminal", () => { + assert.equal( + resolveShortcutCommand(event({ key: "s", metaKey: true, shiftKey: true }), DEFAULT_BINDINGS, { + platform: "MacIntel", + context: { terminalFocus: false }, + }), + "thread.settle", + ); + }); + + it("does not intercept the terminal", () => { + assert.isNull( + resolveShortcutCommand(event({ key: "s", ctrlKey: true, shiftKey: true }), DEFAULT_BINDINGS, { + platform: "Win32", + context: { terminalFocus: true }, + }), + ); + }); +}); + +describe("copy thread reference shortcut", () => { + it("resolves Cmd+Shift+C on macOS and Ctrl+Shift+C elsewhere", () => { + assert.equal( + resolveShortcutCommand(event({ key: "c", metaKey: true, shiftKey: true }), DEFAULT_BINDINGS, { + platform: "MacIntel", + }), + "thread.copyReference", + ); + assert.equal( + resolveShortcutCommand(event({ key: "c", ctrlKey: true, shiftKey: true }), DEFAULT_BINDINGS, { + platform: "Linux", + }), + "thread.copyReference", + ); + }); + + it("leaves terminal copy untouched", () => { + assert.isNull( + resolveShortcutCommand(event({ key: "c", ctrlKey: true, shiftKey: true }), DEFAULT_BINDINGS, { + platform: "Linux", + context: { terminalFocus: true }, + }), + ); + }); +}); + describe("split/new/close terminal shortcuts", () => { it("requires terminalFocus for default split/new/close bindings", () => { assert.isFalse( diff --git a/apps/web/src/lib/attachmentUploadQueue.test.ts b/apps/web/src/lib/attachmentUploadQueue.test.ts index 2b2b94431c80..d7b30e452aab 100644 --- a/apps/web/src/lib/attachmentUploadQueue.test.ts +++ b/apps/web/src/lib/attachmentUploadQueue.test.ts @@ -1,21 +1,35 @@ import { EnvironmentId } from "@t3tools/contracts"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; -import type { ComposerImageAttachment } from "../composerDraftStore"; +import { + composerFileNeedsReattach, + DraftId, + useComposerDraftStore, + type ComposerFileAttachment, + type ComposerImageAttachment, +} from "../composerDraftStore"; const mocks = vi.hoisted(() => ({ + createAssetUrl: vi.fn(), createUploadUrl: Symbol("create-upload-url"), + executeAtomQuery: vi.fn(), removeUpload: Symbol("remove-upload"), runAtomCommand: vi.fn(), readPreparedConnection: vi.fn(), })); vi.mock("@t3tools/client-runtime/state/runtime", () => ({ + executeAtomQuery: mocks.executeAtomQuery, runAtomCommand: mocks.runAtomCommand, + squashAtomCommandFailure: (result: { readonly error: unknown }) => result.error, })); vi.mock("../rpc/atomRegistry", () => ({ appAtomRegistry: {} })); +vi.mock("../state/assets", () => ({ + assetEnvironment: { createUrl: mocks.createAssetUrl }, +})); + vi.mock("../state/attachments", () => ({ attachmentEnvironment: { createUploadUrl: mocks.createUploadUrl, @@ -32,7 +46,9 @@ import { getUploadedAttachments, readAttachmentUpload, releaseAttachmentUpload, - releaseAttachmentUploads, + releaseDraftAttachment, + releaseDraftAttachments, + releasePersistedAttachmentUpload, retryAttachmentUpload, startAttachmentUpload, useAttachmentUploadStore, @@ -110,9 +126,27 @@ function makeImage(id: string): ComposerImageAttachment { }; } +function makeFile(id: string): ComposerFileAttachment { + const file = new File([new Uint8Array([1, 2, 3])], `${id}.pdf`, { + type: "application/pdf", + }); + return { + type: "file", + id, + name: file.name, + mimeType: file.type, + sizeBytes: file.size, + file, + }; +} + describe("attachmentUploadQueue", () => { beforeEach(() => { TestXmlHttpRequest.requests = []; + mocks.createAssetUrl.mockReset(); + mocks.createAssetUrl.mockImplementation((target: unknown) => target); + mocks.executeAtomQuery.mockReset(); + mocks.executeAtomQuery.mockResolvedValue({ _tag: "Success", value: {} }); mocks.runAtomCommand.mockReset(); mocks.readPreparedConnection.mockReset(); mocks.readPreparedConnection.mockReturnValue({ httpBaseUrl: "https://environment.test/" }); @@ -176,7 +210,7 @@ describe("attachmentUploadQueue", () => { }, ]); - releaseAttachmentUploads([image]); + releaseDraftAttachments([image]); expect(readAttachmentUpload(image.id)).toBeUndefined(); expect(mocks.runAtomCommand).toHaveBeenCalledWith( expect.anything(), @@ -189,6 +223,466 @@ describe("attachmentUploadQueue", () => { ); }); + it("uploads generic files and sends file attachment references", async () => { + const file = makeFile("report"); + startAttachmentUpload({ environmentId: firstEnvironment, image: file }); + await Promise.resolve(); + + expect(mocks.runAtomCommand).toHaveBeenCalledWith( + expect.anything(), + mocks.createUploadUrl, + { + environmentId: firstEnvironment, + input: { + type: "file", + name: "report.pdf", + mimeType: "application/pdf", + sizeBytes: 3, + }, + }, + expect.anything(), + ); + + const settled = awaitAttachmentUploads([file.id]); + TestXmlHttpRequest.requests[0]!.complete(); + await settled; + + expect(getUploadedAttachments({ environmentId: firstEnvironment, images: [file] })).toEqual([ + { + type: "file", + id: "pending-environment-1-report.pdf", + name: "report.pdf", + mimeType: "application/pdf", + sizeBytes: 3, + }, + ]); + }); + + it("uses the fallback MIME type for both the upload claim and request header", async () => { + const bytes = new File([new Uint8Array([1, 2, 3])], "unknown.bin"); + const file: ComposerFileAttachment = { + type: "file", + id: "file-without-browser-mime", + name: bytes.name, + mimeType: "application/octet-stream", + sizeBytes: bytes.size, + file: bytes, + }; + + startAttachmentUpload({ environmentId: firstEnvironment, image: file }); + await Promise.resolve(); + + expect(mocks.runAtomCommand).toHaveBeenCalledWith( + expect.anything(), + mocks.createUploadUrl, + { + environmentId: firstEnvironment, + input: { + type: "file", + name: "unknown.bin", + mimeType: "application/octet-stream", + sizeBytes: 3, + }, + }, + expect.anything(), + ); + expect(TestXmlHttpRequest.requests[0]?.headers.get("Content-Type")).toBe( + "application/octet-stream", + ); + + const settled = awaitAttachmentUploads([file.id]); + TestXmlHttpRequest.requests[0]!.complete(); + await settled; + }); + + it("persists a background completion's ids to the draft with no composer mounted", async () => { + const draftId = DraftId.make("draft-background-upload"); + const file = makeFile("background"); + const draftStore = useComposerDraftStore.getState(); + draftStore.addFiles(draftId, [file]); + try { + startAttachmentUpload({ + environmentId: firstEnvironment, + image: file, + draftTarget: draftId, + }); + await Promise.resolve(); + + // No composer effect is subscribed; only the queue can stamp the draft. + const settled = awaitAttachmentUploads([file.id]); + TestXmlHttpRequest.requests[0]!.complete(); + await settled; + + expect(useComposerDraftStore.getState().getComposerDraft(draftId)?.files).toMatchObject([ + { + id: file.id, + uploadedAttachmentId: "pending-environment-1-background.pdf", + uploadEnvironmentId: firstEnvironment, + }, + ]); + } finally { + useComposerDraftStore.getState().clearComposerContent(draftId); + } + }); + + it("verifies an uploaded file reference before restoring it", async () => { + const file: ComposerFileAttachment = { + ...makeFile("restored"), + file: null, + uploadedAttachmentId: "pending-restored-pdf", + uploadEnvironmentId: firstEnvironment, + }; + + startAttachmentUpload({ environmentId: firstEnvironment, image: file }); + await awaitAttachmentUploads([file.id]); + + expect(readAttachmentUpload(file.id)).toEqual({ + status: "ready", + environmentId: firstEnvironment, + attachmentId: "pending-restored-pdf", + }); + expect(mocks.createAssetUrl).toHaveBeenCalledWith({ + environmentId: firstEnvironment, + input: { resource: { _tag: "attachment", attachmentId: "pending-restored-pdf" } }, + }); + expect(TestXmlHttpRequest.requests).toHaveLength(0); + }); + + it("turns an expired persisted file into a marker that a re-pick can replace", async () => { + const draftId = DraftId.make("draft-expired-upload"); + const file: ComposerFileAttachment = { + ...makeFile("expired"), + file: null, + uploadedAttachmentId: "pending-expired-pdf", + uploadEnvironmentId: firstEnvironment, + }; + mocks.executeAtomQuery.mockResolvedValueOnce({ + _tag: "Failure", + error: { _tag: "AssetAttachmentNotFoundError" }, + }); + useComposerDraftStore.getState().addFiles(draftId, [file]); + + try { + startAttachmentUpload({ + environmentId: firstEnvironment, + image: file, + draftTarget: draftId, + }); + await awaitAttachmentUploads([file.id]); + + const marker = useComposerDraftStore.getState().getComposerDraft(draftId)?.files[0]; + expect(readAttachmentUpload(file.id)).toBeUndefined(); + expect(marker?.uploadedAttachmentId).toBeUndefined(); + expect(marker?.uploadEnvironmentId).toBeUndefined(); + expect(marker && composerFileNeedsReattach(marker)).toBe(true); + + const replacementBytes = new File([new Uint8Array([1, 2, 3])], file.name, { + type: file.mimeType, + }); + const replacement: ComposerFileAttachment = { + type: "file", + id: "expired-repicked", + name: file.name, + mimeType: file.mimeType, + sizeBytes: file.sizeBytes, + file: replacementBytes, + }; + useComposerDraftStore.getState().addFiles(draftId, [replacement]); + expect(useComposerDraftStore.getState().getComposerDraft(draftId)?.files).toMatchObject([ + { id: replacement.id, file: replacementBytes }, + ]); + + startAttachmentUpload({ + environmentId: firstEnvironment, + image: replacement, + draftTarget: draftId, + }); + await Promise.resolve(); + const settled = awaitAttachmentUploads([replacement.id]); + TestXmlHttpRequest.requests[0]!.complete(); + await settled; + + expect(useComposerDraftStore.getState().getComposerDraft(draftId)?.files).toMatchObject([ + { + id: replacement.id, + uploadedAttachmentId: "pending-environment-1-expired.pdf", + uploadEnvironmentId: firstEnvironment, + }, + ]); + const removeCalls = mocks.runAtomCommand.mock.calls.filter( + ([, command]) => command === mocks.removeUpload, + ); + expect(removeCalls).toEqual([]); + } finally { + useComposerDraftStore.getState().clearComposerContent(draftId); + } + }); + + it("uploads the original file again when its persisted server upload expired", async () => { + const file: ComposerFileAttachment = { + ...makeFile("recoverable"), + uploadedAttachmentId: "pending-expired-pdf", + uploadEnvironmentId: firstEnvironment, + }; + mocks.executeAtomQuery.mockResolvedValueOnce({ + _tag: "Failure", + error: { _tag: "AssetAttachmentNotFoundError" }, + }); + + startAttachmentUpload({ environmentId: firstEnvironment, image: file }); + // The verify-then-reupload path crosses several awaits before the + // transfer starts; drain microtasks until the XHR exists. + for (let hop = 0; hop < 20 && TestXmlHttpRequest.requests.length === 0; hop += 1) { + await Promise.resolve(); + } + + const settled = awaitAttachmentUploads([file.id]); + TestXmlHttpRequest.requests[0]!.complete(); + await settled; + + expect(readAttachmentUpload(file.id)).toMatchObject({ + status: "ready", + attachmentId: "pending-environment-1-recoverable.pdf", + }); + }); + + it("removes a persisted upload when its draft is discarded during verification", async () => { + const file: ComposerFileAttachment = { + ...makeFile("checking"), + file: null, + uploadedAttachmentId: "pending-checking-pdf", + uploadEnvironmentId: firstEnvironment, + }; + let resolveVerification: (result: { + readonly _tag: "Success"; + readonly value: object; + }) => void = () => {}; + mocks.executeAtomQuery.mockReturnValueOnce( + new Promise((resolve) => { + resolveVerification = resolve; + }), + ); + + startAttachmentUpload({ environmentId: firstEnvironment, image: file }); + releaseDraftAttachment(file); + resolveVerification({ _tag: "Success", value: {} }); + + expect(mocks.runAtomCommand).toHaveBeenCalledWith( + expect.anything(), + mocks.removeUpload, + { + environmentId: firstEnvironment, + input: { attachmentId: "pending-checking-pdf" }, + }, + expect.anything(), + ); + }); + + it("keeps the persisted upload when retrying after a transient verification failure", async () => { + const draftId = DraftId.make("draft-transient-verification"); + const file: ComposerFileAttachment = { + ...makeFile("flaky"), + file: null, + uploadedAttachmentId: "pending-flaky-pdf", + uploadEnvironmentId: firstEnvironment, + }; + mocks.executeAtomQuery.mockResolvedValueOnce({ + _tag: "Failure", + error: new Error("socket closed"), + }); + useComposerDraftStore.getState().addFiles(draftId, [file]); + + try { + startAttachmentUpload({ + environmentId: firstEnvironment, + image: file, + draftTarget: draftId, + }); + await awaitAttachmentUploads([file.id]); + expect(readAttachmentUpload(file.id)).toMatchObject({ + status: "failed", + reason: "Uploaded file could not be verified. Retry when the server reconnects.", + }); + expect(useComposerDraftStore.getState().getComposerDraft(draftId)?.files).toMatchObject([ + { + uploadedAttachmentId: "pending-flaky-pdf", + uploadEnvironmentId: firstEnvironment, + }, + ]); + + // The persisted id is the only server copy of the bytes (`file` is null + // after a reload), so the retry must verify it again, not delete it. + retryAttachmentUpload({ + environmentId: firstEnvironment, + image: file, + draftTarget: draftId, + }); + await awaitAttachmentUploads([file.id]); + + expect(readAttachmentUpload(file.id)).toEqual({ + status: "ready", + environmentId: firstEnvironment, + attachmentId: "pending-flaky-pdf", + }); + expect(useComposerDraftStore.getState().getComposerDraft(draftId)?.files).toMatchObject([ + { + uploadedAttachmentId: "pending-flaky-pdf", + uploadEnvironmentId: firstEnvironment, + }, + ]); + const removeCalls = mocks.runAtomCommand.mock.calls.filter( + ([, command]) => command === mocks.removeUpload, + ); + expect(removeCalls).toEqual([]); + } finally { + useComposerDraftStore.getState().clearComposerContent(draftId); + } + }); + + it("keeps the persisted upload when an environment switch cancels its verification", async () => { + const file: ComposerFileAttachment = { + ...makeFile("moving"), + file: null, + uploadedAttachmentId: "pending-moving-pdf", + uploadEnvironmentId: firstEnvironment, + }; + let resolveVerification: (result: { + readonly _tag: "Success"; + readonly value: object; + }) => void = () => {}; + mocks.executeAtomQuery.mockReturnValueOnce( + new Promise((resolve) => { + resolveVerification = resolve; + }), + ); + + startAttachmentUpload({ environmentId: firstEnvironment, image: file }); + // Switching environments cancels the in-flight verification. The draft + // still references the upload in the first environment, so the cancel + // must not delete it. + startAttachmentUpload({ environmentId: secondEnvironment, image: file }); + resolveVerification({ _tag: "Success", value: {} }); + await awaitAttachmentUploads([file.id]); + + const persistedRemoveCalls = mocks.runAtomCommand.mock.calls.filter( + ([, command, target]) => + command === mocks.removeUpload && + (target as { readonly input: { readonly attachmentId: string } }).input.attachmentId === + "pending-moving-pdf", + ); + expect(persistedRemoveCalls).toEqual([]); + }); + + it("cancels persisted-upload verification when a stash discards its file", async () => { + const file: ComposerFileAttachment = { + ...makeFile("stashed-checking"), + file: null, + uploadedAttachmentId: "pending-stashed-checking-pdf", + uploadEnvironmentId: firstEnvironment, + }; + let resolveVerification: (result: { + readonly _tag: "Success"; + readonly value: object; + }) => void = () => {}; + mocks.executeAtomQuery.mockReturnValueOnce( + new Promise((resolve) => { + resolveVerification = resolve; + }), + ); + + startAttachmentUpload({ environmentId: firstEnvironment, image: file }); + releasePersistedAttachmentUpload({ + id: file.id, + environmentId: firstEnvironment, + attachmentId: "pending-stashed-checking-pdf", + }); + resolveVerification({ _tag: "Success", value: {} }); + await Promise.resolve(); + + expect(readAttachmentUpload(file.id)).toBeUndefined(); + expect(mocks.runAtomCommand).toHaveBeenCalledWith( + expect.anything(), + mocks.removeUpload, + { + environmentId: firstEnvironment, + input: { attachmentId: "pending-stashed-checking-pdf" }, + }, + expect.anything(), + ); + }); + + it("releases the persisted server upload when a hydrated draft is discarded after a reload", () => { + // After a reload the in-memory queue is empty; the draft file only carries + // its persisted attachment id. Discarding it must still delete the + // server-side pending upload. + const file: ComposerFileAttachment = { + ...makeFile("hydrated"), + file: null, + uploadedAttachmentId: "pending-hydrated-pdf", + uploadEnvironmentId: firstEnvironment, + }; + + releaseDraftAttachment(file); + + expect(mocks.runAtomCommand).toHaveBeenCalledWith( + expect.anything(), + mocks.removeUpload, + { + environmentId: firstEnvironment, + input: { attachmentId: "pending-hydrated-pdf" }, + }, + expect.anything(), + ); + }); + + it("routes a live persisted upload through the queue release exactly once", async () => { + const file: ComposerFileAttachment = { + ...makeFile("live"), + file: null, + uploadedAttachmentId: "pending-live-pdf", + uploadEnvironmentId: firstEnvironment, + }; + startAttachmentUpload({ environmentId: firstEnvironment, image: file }); + await awaitAttachmentUploads([file.id]); + expect(readAttachmentUpload(file.id)).toMatchObject({ status: "ready" }); + + releaseDraftAttachment(file); + + expect(readAttachmentUpload(file.id)).toBeUndefined(); + const removeCalls = mocks.runAtomCommand.mock.calls.filter( + ([, command]) => command === mocks.removeUpload, + ); + expect(removeCalls).toEqual([ + [ + expect.anything(), + mocks.removeUpload, + { + environmentId: firstEnvironment, + input: { attachmentId: "pending-live-pdf" }, + }, + expect.anything(), + ], + ]); + }); + + it("deletes a persisted server upload even when browser upload state is gone", () => { + releasePersistedAttachmentUpload({ + id: "stashed-report", + environmentId: firstEnvironment, + attachmentId: "pending-00000000-0000-4000-8000-000000000001-pdf", + }); + + expect(mocks.runAtomCommand).toHaveBeenCalledWith( + expect.anything(), + mocks.removeUpload, + { + environmentId: firstEnvironment, + input: { attachmentId: "pending-00000000-0000-4000-8000-000000000001-pdf" }, + }, + expect.anything(), + ); + }); + it("retries rejected uploads", async () => { const image = makeImage("image-retry"); startAttachmentUpload({ environmentId: firstEnvironment, image }); diff --git a/apps/web/src/lib/attachmentUploadQueue.ts b/apps/web/src/lib/attachmentUploadQueue.ts index 37eb924ca256..b2556bb4c41e 100644 --- a/apps/web/src/lib/attachmentUploadQueue.ts +++ b/apps/web/src/lib/attachmentUploadQueue.ts @@ -3,12 +3,25 @@ import { type ChatAttachment, type EnvironmentId, } from "@t3tools/contracts"; +import { parseScopedThreadKey } from "@t3tools/client-runtime/environment"; import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; -import { runAtomCommand } from "@t3tools/client-runtime/state/runtime"; +import { + deletePendingAttachmentUpload, + runAttachmentUploadCycle, + verifyPersistedAttachmentUpload, + type PersistedAttachmentVerification, +} from "@t3tools/client-runtime/state/attachments"; import { create } from "zustand"; -import type { ComposerImageAttachment } from "../composerDraftStore"; +import { + DraftId, + useComposerDraftStore, + type ComposerFileAttachment, + type ComposerImageAttachment, + type ComposerThreadTarget, +} from "../composerDraftStore"; import { appAtomRegistry } from "../rpc/atomRegistry"; +import { assetEnvironment } from "../state/assets"; import { attachmentEnvironment } from "../state/attachments"; import { readPreparedConnection } from "../state/session"; import type { AttachmentUploadState, ReadyAttachmentUpload } from "./attachmentUploadState"; @@ -25,11 +38,23 @@ export const useAttachmentUploadStore = create(() => ({ })); interface UploadJob { - readonly image: ComposerImageAttachment; + readonly image: ComposerImageAttachment | ComposerFileAttachment; readonly environmentId: EnvironmentId; + /** + * The draft that owned this file when the job started. Completion resolves + * the current owner because the file can move while the upload is pending. + */ + readonly draftTarget?: ComposerThreadTarget; readonly previous?: ReadyAttachmentUpload; + /** + * The draft's persisted server-side upload, to verify instead of re-upload. + * The draft owns this id; the queue never deletes it on cancel or retry. + * Deleting it goes through `releasePersistedAttachmentUpload` only. + */ + readonly persistedAttachmentId?: string; readonly settled: Promise; resolveSettled: () => void; + /** Only ids this queue minted itself. Cancel and retry may delete these. */ attachmentId: string | null; cancelled: boolean; abort: (() => void) | null; @@ -60,25 +85,73 @@ export function readAttachmentUpload(imageId: string): AttachmentUploadState | u return useAttachmentUploadStore.getState().uploadsByImageId[imageId]; } +/** Finds the file's current same-environment draft after any in-flight move. */ +function resolveCurrentFileDraftTarget(job: UploadJob): ComposerThreadTarget | undefined { + if (job.draftTarget === undefined || job.image.type !== "file") { + return undefined; + } + const store = useComposerDraftStore.getState(); + for (const [key, draft] of Object.entries(store.draftsByThreadKey)) { + if (!draft.files.some((file) => file.id === job.image.id)) { + continue; + } + const draftSession = store.draftThreadsByThreadKey[key]; + if (draftSession !== undefined) { + if (draftSession.environmentId === job.environmentId) { + return DraftId.make(key); + } + continue; + } + // Tests and legacy callers can use a DraftId without session metadata. + // Only its original job supplies enough environment identity to trust it. + if (typeof job.draftTarget === "string" && job.draftTarget === key) { + return DraftId.make(key); + } + const threadRef = parseScopedThreadKey(key); + if (threadRef?.environmentId === job.environmentId) { + return threadRef; + } + } + return undefined; +} + +/** + * Persists a finished upload's ids onto the draft that owns the file. The + * mounted composer effect performs the same write for live UI updates, but a + * background completion (user navigated away, upload finished, reload) must + * not depend on a mounted composer to survive. `setFileUpload` no-ops when + * the draft row is gone or already carries these ids. + */ +function stampDraftFileUpload(job: UploadJob, attachmentId: string): void { + const draftTarget = resolveCurrentFileDraftTarget(job); + if (draftTarget === undefined) { + return; + } + useComposerDraftStore + .getState() + .setFileUpload(draftTarget, job.image.id, job.environmentId, attachmentId); +} + function deletePendingUpload(environmentId: EnvironmentId, attachmentId: string): void { - void runAtomCommand( - appAtomRegistry, - attachmentEnvironment.remove, - { environmentId, input: { attachmentId } }, - { reportFailure: false, reportDefect: false }, - ); + deletePendingAttachmentUpload({ + registry: appAtomRegistry, + remove: attachmentEnvironment.remove, + environmentId, + attachmentId, + }); } function uploadBytes(input: { readonly url: string; readonly file: File; + readonly mimeType: string; readonly onProgress: (progress: number) => void; }): { readonly done: Promise; readonly abort: () => void } { const xhr = new XMLHttpRequest(); const done = new Promise((resolve, reject) => { xhr.open("POST", input.url, true); xhr.timeout = UPLOAD_TIMEOUT_MS; - xhr.setRequestHeader("Content-Type", input.file.type); + xhr.setRequestHeader("Content-Type", input.mimeType); xhr.upload.addEventListener("progress", (event) => { if (event.lengthComputable && event.total > 0) { input.onProgress(event.loaded / event.total); @@ -101,109 +174,162 @@ function uploadBytes(input: { } async function runUpload(job: UploadJob): Promise { - const mimeType = PROVIDER_SEND_TURN_SUPPORTED_IMAGE_MIME_TYPES.find( - (supportedMimeType) => supportedMimeType === job.image.mimeType.toLowerCase(), - ); - if (!mimeType) { - setUploadState(job.image.id, { - status: "failed", + if (job.persistedAttachmentId) { + const verification = await verifyPersistedAttachmentUpload({ + registry: appAtomRegistry, + createAssetUrl: assetEnvironment.createUrl, environmentId: job.environmentId, - reason: "Unsupported image type", - ...(job.previous ? { previous: job.previous } : {}), + attachmentId: job.persistedAttachmentId, }); - return; - } - - const minted = await runAtomCommand( - appAtomRegistry, - attachmentEnvironment.createUploadUrl, - { - environmentId: job.environmentId, - input: { - name: job.image.name, - mimeType, - sizeBytes: job.image.file.size, - }, - }, - { reportFailure: false }, - ); - if (job.cancelled) { - if (minted._tag === "Success") { - deletePendingUpload(job.environmentId, minted.value.attachmentId); + if (job.cancelled) { + return; + } + if (verification.status === "verified") { + setUploadState(job.image.id, { + status: "ready", + environmentId: job.environmentId, + attachmentId: job.persistedAttachmentId, + }); + stampDraftFileUpload(job, job.persistedAttachmentId); + return; + } + if (verification.status === "missing" && !job.image.file && job.image.type === "file") { + const draftTarget = resolveCurrentFileDraftTarget(job); + if ( + draftTarget !== undefined && + useComposerDraftStore + .getState() + .markFileUploadMissing( + draftTarget, + job.image.id, + job.environmentId, + job.persistedAttachmentId, + ) + ) { + clearUploadState(job.image.id); + return; + } + } + if (verification.status === "failed" || !job.image.file) { + // No `attachmentId` here: a failed state's id marks a pending upload + // this queue minted, which retry and release then delete. The persisted + // id is the only server copy of a hydrated file, so a transient + // verification failure must leave it in place for the next retry. + setUploadState(job.image.id, { + status: "failed", + environmentId: job.environmentId, + reason: + verification.status === "missing" + ? "Uploaded file expired. Remove it and attach it again." + : "Uploaded file could not be verified. Retry when the server reconnects.", + ...(job.previous ? { previous: job.previous } : {}), + }); + return; } - return; } - if (minted._tag !== "Success") { + + const mimeType = + job.image.type === "file" + ? job.image.mimeType.toLowerCase() + : PROVIDER_SEND_TURN_SUPPORTED_IMAGE_MIME_TYPES.find( + (supportedMimeType) => supportedMimeType === job.image.mimeType.toLowerCase(), + ); + if (!mimeType) { setUploadState(job.image.id, { status: "failed", environmentId: job.environmentId, - reason: "Upload could not start", + reason: "Unsupported image type", ...(job.previous ? { previous: job.previous } : {}), }); return; } - job.attachmentId = minted.value.attachmentId; - - const connection = readPreparedConnection(job.environmentId); - const url = connection ? resolveAssetUrl(connection.httpBaseUrl, minted.value.relativeUrl) : null; - if (!url) { + const file = job.image.file; + if (!file) { setUploadState(job.image.id, { status: "failed", environmentId: job.environmentId, - reason: "Not connected", - attachmentId: minted.value.attachmentId, + reason: "Original file is no longer available", ...(job.previous ? { previous: job.previous } : {}), }); return; } let lastStep = -1; - const upload = uploadBytes({ - url, - file: job.image.file, - onProgress: (progress) => { - const step = Math.floor(progress * 20); - if (step === lastStep || job.cancelled) { - return; + const result = await runAttachmentUploadCycle({ + registry: appAtomRegistry, + createUploadUrl: attachmentEnvironment.createUploadUrl, + remove: attachmentEnvironment.remove, + environmentId: job.environmentId, + upload: { + ...(job.image.type === "file" ? { type: "file" as const } : {}), + name: job.image.name, + mimeType, + sizeBytes: file.size, + }, + resolveUploadUrl: (relativeUrl) => { + const connection = readPreparedConnection(job.environmentId); + return connection ? resolveAssetUrl(connection.httpBaseUrl, relativeUrl) : null; + }, + transport: (url) => + uploadBytes({ + url, + file, + mimeType, + onProgress: (progress) => { + const step = Math.floor(progress * 20); + if (step === lastStep || job.cancelled) { + return; + } + lastStep = step; + setUploadState(job.image.id, { + status: "uploading", + environmentId: job.environmentId, + progress, + ...(job.previous ? { previous: job.previous } : {}), + }); + }, + }), + onMinted: (attachmentId) => { + if (job.cancelled) { + return "cancel"; } - lastStep = step; - setUploadState(job.image.id, { - status: "uploading", - environmentId: job.environmentId, - progress, - ...(job.previous ? { previous: job.previous } : {}), - }); + job.attachmentId = attachmentId; + return "continue"; + }, + onTransferStart: (abort) => { + job.abort = abort; }, }); - job.abort = upload.abort; - - try { - await upload.done; - if (job.cancelled) { - return; - } + job.abort = null; + if (result.status === "cancelled" || job.cancelled) { + return; + } + if (result.status === "uploaded") { setUploadState(job.image.id, { status: "ready", environmentId: job.environmentId, - attachmentId: minted.value.attachmentId, + attachmentId: result.attachmentId, }); + stampDraftFileUpload(job, result.attachmentId); if (job.previous) { deletePendingUpload(job.previous.environmentId, job.previous.attachmentId); } - } catch (error) { - if (job.cancelled) { - return; - } - setUploadState(job.image.id, { - status: "failed", - environmentId: job.environmentId, - reason: error instanceof Error ? error.message : "Upload failed", - attachmentId: minted.value.attachmentId, - ...(job.previous ? { previous: job.previous } : {}), - }); - } finally { - job.abort = null; + return; } + setUploadState(job.image.id, { + status: "failed", + environmentId: job.environmentId, + reason: + result.step === "mint" + ? "Upload could not start" + : result.step === "resolve-url" + ? "Not connected" + : result.error instanceof Error + ? result.error.message + : "Upload failed", + ...(result.attachmentId ? { attachmentId: result.attachmentId } : {}), + ...(job.previous ? { previous: job.previous } : {}), + }); } function pumpUploads(): void { @@ -249,7 +375,9 @@ function pumpUploads(): void { export function startAttachmentUpload(input: { readonly environmentId: EnvironmentId; - readonly image: ComposerImageAttachment; + readonly image: ComposerImageAttachment | ComposerFileAttachment; + /** Draft that owns the file; lets a background completion persist its ids. */ + readonly draftTarget?: ComposerThreadTarget; }): void { const existingJob = jobsByImageId.get(input.image.id); if (existingJob?.environmentId === input.environmentId) { @@ -287,7 +415,13 @@ export function startAttachmentUpload(input: { const job: UploadJob = { image: input.image, environmentId: input.environmentId, + ...(input.draftTarget !== undefined ? { draftTarget: input.draftTarget } : {}), ...(previous ? { previous } : {}), + ...(input.image.type === "file" && + input.image.uploadEnvironmentId === input.environmentId && + input.image.uploadedAttachmentId + ? { persistedAttachmentId: input.image.uploadedAttachmentId } + : {}), settled, resolveSettled, attachmentId: null, @@ -306,6 +440,11 @@ export function startAttachmentUpload(input: { pumpUploads(); } +/** + * Stops the job and deletes only the pending upload it minted itself. A + * persisted draft upload survives cancellation (an environment switch cancels + * the old job, and the draft still references that server copy). + */ export function cancelAttachmentUpload(imageId: string): void { const job = jobsByImageId.get(imageId); if (!job) { @@ -340,12 +479,42 @@ export function releaseAttachmentUpload(imageId: string): void { clearUploadState(imageId); } +export function releasePersistedAttachmentUpload(input: { + readonly id: string; + readonly environmentId: EnvironmentId; + readonly attachmentId: string; +}): void { + const upload = readAttachmentUpload(input.id); + if ( + upload?.status === "ready" && + upload.environmentId === input.environmentId && + upload.attachmentId === input.attachmentId + ) { + releaseAttachmentUpload(input.id); + return; + } + const job = jobsByImageId.get(input.id); + if ( + job?.environmentId === input.environmentId && + job.persistedAttachmentId === input.attachmentId + ) { + // Tears down the in-flight verification or re-upload. The queue only + // deletes ids it minted, so the persisted id still needs the delete below. + releaseAttachmentUpload(input.id); + } + deletePendingUpload(input.environmentId, input.attachmentId); +} + export function retryAttachmentUpload(input: { readonly environmentId: EnvironmentId; - readonly image: ComposerImageAttachment; + readonly image: ComposerImageAttachment | ComposerFileAttachment; + readonly draftTarget?: ComposerThreadTarget; }): void { const previous = readAttachmentUpload(input.image.id); cancelAttachmentUpload(input.image.id); + // A failed state's `attachmentId` is always one this queue minted, so this + // never deletes a persisted draft upload. Retrying a hydrated file whose + // verification failed leaves the server copy alone and verifies it again. if (previous?.status === "failed" && previous.attachmentId) { deletePendingUpload(previous.environmentId, previous.attachmentId); } @@ -357,13 +526,30 @@ export function retryAttachmentUpload(input: { startAttachmentUpload(input); } +/** + * Checks that a stashed upload still exists on the server. Pending uploads + * are swept after 24 hours, so a stash restore asks first instead of handing + * the composer a dead reference. + */ +export function verifyStashedAttachmentUpload(input: { + readonly environmentId: EnvironmentId; + readonly attachmentId: string; +}): Promise { + return verifyPersistedAttachmentUpload({ + registry: appAtomRegistry, + createAssetUrl: assetEnvironment.createUrl, + environmentId: input.environmentId, + attachmentId: input.attachmentId, + }); +} + export async function awaitAttachmentUploads(imageIds: ReadonlyArray): Promise { await Promise.all(imageIds.map((imageId) => jobsByImageId.get(imageId)?.settled)); } export function getUploadedAttachments(input: { readonly environmentId: EnvironmentId; - readonly images: ReadonlyArray; + readonly images: ReadonlyArray; }): ChatAttachment[] | null { const attachments: ChatAttachment[] = []; for (const image of input.images) { @@ -372,7 +558,7 @@ export function getUploadedAttachments(input: { return null; } attachments.push({ - type: "image", + type: image.type, id: upload.attachmentId, name: image.name, mimeType: image.mimeType, @@ -382,8 +568,42 @@ export function getUploadedAttachments(input: { return attachments; } -export function releaseAttachmentUploads(images: ReadonlyArray): void { - for (const image of images) { - releaseAttachmentUpload(image.id); +/** + * The one owner for discarding a draft attachment's server-side upload. The + * queue-keyed release only sees in-memory state, so after a reload it finds + * nothing and the pending upload leaks. When the draft carries a persisted + * `uploadedAttachmentId` (which survives reloads), route through the persisted + * release; it still prefers the queue path when the queue owns that same + * attachment. Every draft discard path must funnel through here. + */ +export function releaseDraftAttachment( + attachment: ComposerImageAttachment | ComposerFileAttachment, +): void { + if ( + attachment.type === "file" && + attachment.uploadedAttachmentId !== undefined && + attachment.uploadEnvironmentId !== undefined + ) { + releasePersistedAttachmentUpload({ + id: attachment.id, + environmentId: attachment.uploadEnvironmentId, + attachmentId: attachment.uploadedAttachmentId, + }); + // A failed re-upload after verification can hold a newer minted + // attachment under the queue key. Release whatever is left so neither + // copy stays behind. (The pending delete is idempotent server-side.) + if (jobsByImageId.has(attachment.id) || readAttachmentUpload(attachment.id)) { + releaseAttachmentUpload(attachment.id); + } + return; + } + releaseAttachmentUpload(attachment.id); +} + +export function releaseDraftAttachments( + attachments: ReadonlyArray, +): void { + for (const attachment of attachments) { + releaseDraftAttachment(attachment); } } diff --git a/apps/web/src/lib/attachmentUploadState.test.ts b/apps/web/src/lib/attachmentUploadState.test.ts index 3156d7200778..1fd44c04dd01 100644 --- a/apps/web/src/lib/attachmentUploadState.test.ts +++ b/apps/web/src/lib/attachmentUploadState.test.ts @@ -34,7 +34,7 @@ describe("attachmentUploadBlockReason", () => { "image-1": { status: "uploading", environmentId, progress: 0.5 }, }, }), - ).toBe("Images still uploading"); + ).toBe("Attachments still uploading"); }); it("asks the user to retry or remove failed uploads", () => { @@ -46,7 +46,7 @@ describe("attachmentUploadBlockReason", () => { "image-1": { status: "failed", environmentId, reason: "Upload failed" }, }, }), - ).toBe("Retry or remove the failed image"); + ).toBe("Retry or remove the failed attachment"); }); it("does not accept an upload from another environment", () => { @@ -62,7 +62,7 @@ describe("attachmentUploadBlockReason", () => { }, }, }), - ).toBe("Image still uploading"); + ).toBe("Attachment still uploading"); }); }); diff --git a/apps/web/src/lib/attachmentUploadState.ts b/apps/web/src/lib/attachmentUploadState.ts index 6ca2d2bc155c..d77e8d0dcb48 100644 --- a/apps/web/src/lib/attachmentUploadState.ts +++ b/apps/web/src/lib/attachmentUploadState.ts @@ -18,6 +18,11 @@ export type AttachmentUploadState = readonly status: "failed"; readonly environmentId: EnvironmentId; readonly reason: string; + /** + * A pending upload the queue minted for this failed attempt. Retry and + * release delete it, so it must never carry a draft's persisted + * attachment id. + */ readonly attachmentId?: string; readonly previous?: ReadyAttachmentUpload; }; @@ -40,10 +45,12 @@ export function attachmentUploadBlockReason(input: { } if (failed > 0) { - return failed === 1 ? "Retry or remove the failed image" : "Retry or remove the failed images"; + return failed === 1 + ? "Retry or remove the failed attachment" + : "Retry or remove the failed attachments"; } if (pending > 0) { - return pending === 1 ? "Image still uploading" : "Images still uploading"; + return pending === 1 ? "Attachment still uploading" : "Attachments still uploading"; } return null; } diff --git a/apps/web/src/lib/chatThreadActions.test.ts b/apps/web/src/lib/chatThreadActions.test.ts index 0902d8de7950..c145404a74e2 100644 --- a/apps/web/src/lib/chatThreadActions.test.ts +++ b/apps/web/src/lib/chatThreadActions.test.ts @@ -1,9 +1,16 @@ import { scopeProjectRef } from "@t3tools/client-runtime/environment"; -import { EnvironmentId, ProjectId } from "@t3tools/contracts"; +import { + EnvironmentId, + ProjectId, + ProviderInstanceId, + type ModelSelection, +} from "@t3tools/contracts"; import { describe, expect, it, vi } from "vite-plus/test"; import { resolveThreadActionProjectRef, + hasExplicitComposerModelSelection, resolveNewDraftStartFromOrigin, + resolveNewThreadModelSelectionOverride, startNewThreadFromContext, type ChatThreadActionContext, } from "./chatThreadActions"; @@ -11,6 +18,14 @@ import { const ENVIRONMENT_ID = EnvironmentId.make("environment-1"); const PROJECT_ID = ProjectId.make("project-1"); const FALLBACK_PROJECT_ID = ProjectId.make("project-2"); +const PROJECT_DEFAULT_SELECTION: ModelSelection = { + instanceId: ProviderInstanceId.make("codex"), + model: "project-default", +}; +const CARRIED_SELECTION: ModelSelection = { + instanceId: ProviderInstanceId.make("codex"), + model: "carried-model", +}; function createContext(overrides: Partial = {}): ChatThreadActionContext { return { @@ -23,6 +38,55 @@ function createContext(overrides: Partial = {}): ChatTh } describe("chatThreadActions", () => { + it("only treats an active stored selection marked explicit as an explicit pick", () => { + const draft = { + activeProvider: PROJECT_DEFAULT_SELECTION.instanceId, + modelSelectionByProvider: { + [PROJECT_DEFAULT_SELECTION.instanceId]: PROJECT_DEFAULT_SELECTION, + }, + modelSelectionExplicit: true, + }; + + expect(hasExplicitComposerModelSelection(draft)).toBe(true); + expect(hasExplicitComposerModelSelection({ ...draft, modelSelectionExplicit: false })).toBe( + false, + ); + expect(hasExplicitComposerModelSelection({ ...draft, activeProvider: null })).toBe(false); + }); + + it("does not carry a non-explicit model from the destination draft back into itself", () => { + expect( + resolveNewThreadModelSelectionOverride({ + projectDefaultSelection: null, + carrySelection: CARRIED_SELECTION, + carrySourceDraftId: "draft-a", + destinationDraftId: "draft-a", + }), + ).toBeNull(); + }); + + it("still carries models between different threads when the project has no default", () => { + expect( + resolveNewThreadModelSelectionOverride({ + projectDefaultSelection: null, + carrySelection: CARRIED_SELECTION, + carrySourceDraftId: "draft-a", + destinationDraftId: "draft-b", + }), + ).toEqual(CARRIED_SELECTION); + }); + + it("keeps the project default above any carried selection", () => { + expect( + resolveNewThreadModelSelectionOverride({ + projectDefaultSelection: PROJECT_DEFAULT_SELECTION, + carrySelection: CARRIED_SELECTION, + carrySourceDraftId: "draft-a", + destinationDraftId: "draft-b", + }), + ).toEqual(PROJECT_DEFAULT_SELECTION); + }); + it("only applies the start-from-origin default to new worktree drafts", () => { expect( resolveNewDraftStartFromOrigin({ diff --git a/apps/web/src/lib/chatThreadActions.ts b/apps/web/src/lib/chatThreadActions.ts index 3aa7db2c2627..c14a26d03d1c 100644 --- a/apps/web/src/lib/chatThreadActions.ts +++ b/apps/web/src/lib/chatThreadActions.ts @@ -1,6 +1,16 @@ import { scopeProjectRef } from "@t3tools/client-runtime/environment"; -import type { EnvironmentId, ProjectId, ScopedProjectRef } from "@t3tools/contracts"; -import type { DraftThreadEnvMode } from "../composerDraftStore"; +import type { + EnvironmentId, + ModelSelection, + ProjectId, + ScopedProjectRef, +} from "@t3tools/contracts"; +import type { ComposerThreadDraftState, DraftThreadEnvMode } from "../composerDraftStore"; + +type ComposerModelSelectionState = Pick< + ComposerThreadDraftState, + "activeProvider" | "modelSelectionByProvider" | "modelSelectionExplicit" +>; interface ThreadContextLike { environmentId: EnvironmentId; @@ -34,6 +44,30 @@ export function resolveNewDraftStartFromOrigin(input: { return input.envMode === "worktree" && input.newWorktreesStartFromOrigin; } +export function resolveNewThreadModelSelectionOverride(input: { + readonly projectDefaultSelection: ModelSelection | null; + readonly carrySelection: ModelSelection | null; + readonly carrySourceDraftId: string | null; + readonly destinationDraftId: string; +}): ModelSelection | null { + return ( + input.projectDefaultSelection ?? + (input.carrySourceDraftId === input.destinationDraftId ? null : input.carrySelection) + ); +} + +export function hasExplicitComposerModelSelection( + draft: ComposerModelSelectionState | null | undefined, +): boolean { + const activeProvider = draft?.activeProvider; + return ( + draft?.modelSelectionExplicit === true && + activeProvider !== null && + activeProvider !== undefined && + draft.modelSelectionByProvider[activeProvider] !== undefined + ); +} + export function resolveThreadActionProjectRef( context: ChatThreadActionContext, ): ScopedProjectRef | null { diff --git a/apps/web/src/lib/composerDraftUploads.ts b/apps/web/src/lib/composerDraftUploads.ts index a9b8a357725e..3420f715df4f 100644 --- a/apps/web/src/lib/composerDraftUploads.ts +++ b/apps/web/src/lib/composerDraftUploads.ts @@ -1,23 +1,40 @@ import type { ScopedProjectRef, ScopedThreadRef } from "@t3tools/contracts"; +import { scopedThreadKey } from "@t3tools/client-runtime/environment"; import { type DraftId, useComposerDraftStore } from "../composerDraftStore"; -import { releaseAttachmentUploads } from "./attachmentUploadQueue"; +import { releaseDraftAttachments } from "./attachmentUploadQueue"; export function releaseComposerDraftUploads(target: ScopedThreadRef | DraftId): void { const draft = useComposerDraftStore.getState().getComposerDraft(target); if (draft) { - releaseAttachmentUploads(draft.images); + releaseDraftAttachments([...draft.images, ...draft.files]); } } -export function releaseProjectDraftUploads(projectRef: ScopedProjectRef): void { +/** + * Releases every upload a deleted project's drafts still hold. Draft-thread + * sessions carry their project ref, but drafts on the project's real threads + * live in `draftsByThreadKey` under scoped thread keys with no project in the + * key, so the caller passes the project's thread refs alongside. + */ +export function releaseProjectDraftUploads( + projectRef: ScopedProjectRef, + projectThreadRefs: ReadonlyArray = [], +): void { const store = useComposerDraftStore.getState(); for (const [draftKey, session] of Object.entries(store.draftThreadsByThreadKey)) { if ( session.environmentId === projectRef.environmentId && session.projectId === projectRef.projectId ) { - releaseAttachmentUploads(store.draftsByThreadKey[draftKey]?.images ?? []); + const draft = store.draftsByThreadKey[draftKey]; + releaseDraftAttachments(draft ? [...draft.images, ...draft.files] : []); + } + } + for (const threadRef of projectThreadRefs) { + const draft = store.draftsByThreadKey[scopedThreadKey(threadRef)]; + if (draft) { + releaseDraftAttachments([...draft.images, ...draft.files]); } } } diff --git a/apps/web/src/lib/contextWindow.test.ts b/apps/web/src/lib/contextWindow.test.ts index c3226884a31d..b87c0664403e 100644 --- a/apps/web/src/lib/contextWindow.test.ts +++ b/apps/web/src/lib/contextWindow.test.ts @@ -26,6 +26,7 @@ describe("contextWindow", () => { usedTokens: 14_000, maxTokens: 258_000, compactsAutomatically: true, + autoCompactThreshold: 200_000, }), ]); @@ -34,6 +35,7 @@ describe("contextWindow", () => { expect(snapshot?.totalProcessedTokens).toBeNull(); expect(snapshot?.maxTokens).toBe(258_000); expect(snapshot?.compactsAutomatically).toBe(true); + expect(snapshot?.autoCompactThreshold).toBe(200_000); }); it("ignores malformed payloads", () => { diff --git a/apps/web/src/lib/contextWindow.ts b/apps/web/src/lib/contextWindow.ts index 80f7d31cf2f9..3ba24cb2c2b4 100644 --- a/apps/web/src/lib/contextWindow.ts +++ b/apps/web/src/lib/contextWindow.ts @@ -88,6 +88,7 @@ export function deriveLatestContextWindowSnapshot( toolUses: asFiniteNumber(payload?.toolUses), durationMs: asFiniteNumber(payload?.durationMs), compactsAutomatically: asBoolean(payload?.compactsAutomatically) ?? false, + autoCompactThreshold: asFiniteNumber(payload?.autoCompactThreshold), updatedAt: activity.createdAt, }; } diff --git a/apps/web/src/lib/diffRendering.test.ts b/apps/web/src/lib/diffRendering.test.ts index 13cd58984de8..9ffaaf27d68a 100644 --- a/apps/web/src/lib/diffRendering.test.ts +++ b/apps/web/src/lib/diffRendering.test.ts @@ -7,12 +7,6 @@ import { } from "./diffRendering"; describe("buildPatchCacheKey", () => { - it("returns a stable cache key for identical content", () => { - const patch = "diff --git a/a.ts b/a.ts\n+console.log('hello')"; - - expect(buildPatchCacheKey(patch)).toBe(buildPatchCacheKey(patch)); - }); - it("normalizes outer whitespace before hashing", () => { const patch = "diff --git a/a.ts b/a.ts\n+console.log('hello')"; diff --git a/apps/web/src/lib/imageCompression.test.ts b/apps/web/src/lib/imageCompression.test.ts index 63712ca7e295..435c51ef86ee 100644 --- a/apps/web/src/lib/imageCompression.test.ts +++ b/apps/web/src/lib/imageCompression.test.ts @@ -3,10 +3,20 @@ import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import { compressImageForStash, compressImageToByteLimit, + isHeicImageFile, MAX_COMPRESSIBLE_SOURCE_BYTES, MAX_STASH_IMAGE_DATA_URL_CHARS, + prepareImageForAttachment, } from "./imageCompression"; +const mocks = vi.hoisted(() => ({ + heicTo: vi.fn(), +})); + +vi.mock("heic-to/csp", () => ({ + heicTo: mocks.heicTo, +})); + /** * jsdom has no real canvas/codec, so the re-encode path is exercised with * stubbed `createImageBitmap` + `OffscreenCanvas`. The encoder stub returns a @@ -22,6 +32,45 @@ function makeFile(sizeBytes: number, type = "image/png"): File { return new File([new Uint8Array(sizeBytes).fill(7)], "shot.png", { type }); } +function makeHeicFile(options?: { + name?: string; + type?: string; + width?: number; + height?: number; + lastModified?: number; +}): File { + const encoder = new TextEncoder(); + const makeBox = (name: string, ...contents: Uint8Array[]) => { + const bytes = new Uint8Array(8 + contents.reduce((size, content) => size + content.length, 0)); + new DataView(bytes.buffer).setUint32(0, bytes.length); + bytes.set(encoder.encode(name), 4); + let offset = 8; + for (const content of contents) { + bytes.set(content, offset); + offset += content.length; + } + return bytes; + }; + + const dimensions = new Uint8Array(12); + const view = new DataView(dimensions.buffer); + view.setUint32(4, options?.width ?? 4000); + view.setUint32(8, options?.height ?? 3000); + const properties = makeBox("iprp", makeBox("ipco", makeBox("ispe", dimensions))); + + return new File( + [ + makeBox("ftyp", encoder.encode("heic"), new Uint8Array(4)), + makeBox("meta", new Uint8Array(4), properties), + ], + options?.name ?? "photo.heic", + { + type: options?.type ?? "image/heic", + ...(options?.lastModified !== undefined ? { lastModified: options.lastModified } : {}), + }, + ); +} + /** * Installs a fake bitmap + canvas whose encoded size follows `sizeForQuality`. * `supportsWebp: false` makes `convertToBlob` hand back a differently-typed @@ -63,6 +112,7 @@ function stubCanvasPipeline( } afterEach(() => { + mocks.heicTo.mockReset(); vi.unstubAllGlobals(); globalThis.createImageBitmap = originalCreateImageBitmap; globalThis.OffscreenCanvas = originalOffscreenCanvas; @@ -251,3 +301,148 @@ describe("compressImageForStash", () => { expect(smallestRequested).toBeLessThan(800); }); }); + +describe("HEIC attachment preparation", () => { + it("recognizes HEIC and HEIF MIME types and case-insensitive file extensions", () => { + expect(isHeicImageFile({ name: "photo.bin", type: "image/heic" })).toBe(true); + expect(isHeicImageFile({ name: "photo.bin", type: "image/heif" })).toBe(true); + expect(isHeicImageFile({ name: "photo.heic", type: "image/heic-sequence" })).toBe(false); + expect(isHeicImageFile({ name: "photo.heif", type: "image/heif-sequence" })).toBe(false); + expect(isHeicImageFile({ name: "IMG_1234.HEIC", type: "" })).toBe(true); + expect(isHeicImageFile({ name: "photo.heif", type: "application/octet-stream" })).toBe(true); + expect(isHeicImageFile({ name: "photo.png", type: "image/png" })).toBe(false); + expect(isHeicImageFile({ name: "photo.heic", type: "image/png" })).toBe(false); + expect(isHeicImageFile({ name: "photo.heif", type: "image/jpeg" })).toBe(false); + }); + + it("converts a HEIC photo with a missing MIME type into a named JPEG", async () => { + const original = makeHeicFile({ + name: "IMG_1234.HEIC", + type: "", + lastModified: 123, + }); + mocks.heicTo.mockResolvedValueOnce( + new Blob([new Uint8Array([4, 5, 6, 7])], { type: "image/jpeg" }), + ); + + const result = await prepareImageForAttachment(original, 1024); + + expect(mocks.heicTo).toHaveBeenCalledWith({ + blob: original, + type: "image/jpeg", + quality: 0.92, + }); + expect(result.ok && result.file.name).toBe("IMG_1234.jpg"); + expect(result.ok && result.file.type).toBe("image/jpeg"); + expect(result.ok && result.file.size).toBe(4); + expect(result.ok && result.file.lastModified).toBe(123); + expect(result.ok && result.recompressed).toBe(true); + }); + + it("keeps oversized converted photos in JPEG format while shrinking them", async () => { + const original = makeHeicFile({ + name: "photo.heif", + type: "image/heif", + }); + mocks.heicTo.mockResolvedValueOnce( + new Blob([new Uint8Array(2_000_000)], { type: "image/jpeg" }), + ); + const { fillRect } = stubCanvasPipeline(() => 200_000); + + const result = await prepareImageForAttachment(original, 1_000_000); + + expect(result.ok && result.file.name).toBe("photo.jpg"); + expect(result.ok && result.file.type).toBe("image/jpeg"); + expect(result.ok && result.file.size).toBeLessThanOrEqual(1_000_000); + expect(fillRect).toHaveBeenCalled(); + }); + + it("compresses JPEG intermediates above the source safety ceiling", async () => { + const original = makeHeicFile({ + name: "large.heic", + type: "image/heic", + }); + mocks.heicTo.mockResolvedValueOnce( + new Blob([new Uint8Array(MAX_COMPRESSIBLE_SOURCE_BYTES + 1)], { + type: "image/jpeg", + }), + ); + const { close } = stubCanvasPipeline(() => 200_000); + + const result = await prepareImageForAttachment(original, 1_000_000); + + expect(result.ok && result.file.name).toBe("large.jpg"); + expect(result.ok && result.file.type).toBe("image/jpeg"); + expect(result.ok && result.file.size).toBeLessThanOrEqual(1_000_000); + expect(close).toHaveBeenCalled(); + }); + + it.each([ + { label: "24 MP", width: 5712, height: 4284 }, + { label: "48 MP", width: 8064, height: 6048 }, + ])("accepts $label HEIC photos", async ({ width, height }) => { + const original = makeHeicFile({ width, height }); + mocks.heicTo.mockResolvedValueOnce(new Blob(["jpeg"], { type: "image/jpeg" })); + + const result = await prepareImageForAttachment(original, 1024); + + expect(result.ok && result.file.type).toBe("image/jpeg"); + expect(mocks.heicTo).toHaveBeenCalledOnce(); + }); + + it("rejects oversized HEIC dimensions before loading the decoder", async () => { + const original = makeHeicFile({ width: 16_000, height: 4001 }); + + expect(await prepareImageForAttachment(original, 1024)).toEqual({ + ok: false, + reason: "too-large", + }); + expect(mocks.heicTo).not.toHaveBeenCalled(); + }); + + it("rejects invalid HEIC metadata before loading the decoder", async () => { + const original = new File([new Uint8Array([1, 2, 3])], "broken.heic", { + type: "image/heic", + }); + + expect(await prepareImageForAttachment(original, 1024)).toEqual({ + ok: false, + reason: "unreadable", + }); + expect(mocks.heicTo).not.toHaveBeenCalled(); + }); + + it("reports unreadable when HEIC decoding fails", async () => { + const original = makeHeicFile({ + name: "broken.heic", + type: "image/heic", + }); + mocks.heicTo.mockRejectedValueOnce(new Error("Invalid HEIC image")); + + expect(await prepareImageForAttachment(original, 1024)).toEqual({ + ok: false, + reason: "unreadable", + }); + }); + + it("rejects unsafe HEIC sources before loading the decoder", async () => { + const original = new File(["photo"], "large.heic", { type: "image/heic" }); + Object.defineProperty(original, "size", { value: MAX_COMPRESSIBLE_SOURCE_BYTES + 1 }); + + expect(await prepareImageForAttachment(original, 1024)).toEqual({ + ok: false, + reason: "too-large", + }); + expect(mocks.heicTo).not.toHaveBeenCalled(); + }); + + it("leaves supported images untouched without loading the HEIC decoder", async () => { + const original = makeFile(1024); + + const result = await prepareImageForAttachment(original, 2048); + + expect(result.ok && result.file).toBe(original); + expect(result.ok && result.recompressed).toBe(false); + expect(mocks.heicTo).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/lib/imageCompression.ts b/apps/web/src/lib/imageCompression.ts index be45024f38c4..c699886c44eb 100644 --- a/apps/web/src/lib/imageCompression.ts +++ b/apps/web/src/lib/imageCompression.ts @@ -8,7 +8,8 @@ * `PROVIDER_SEND_TURN_MAX_IMAGE_BYTES` wire cap and shrinks them to fit * via `compressImageToByteLimit` instead of rejecting the paste. * - * Images already within budget pass through untouched. + * Supported images already within budget pass through untouched. HEIC/HEIF + * photos are decoded to JPEG first because providers cannot consume them. */ /** @@ -24,6 +25,8 @@ export const MAX_STASH_IMAGE_DATA_URL_CHARS = 1_300_000; * ImageBitmap can OOM the tab — beyond this we refuse rather than risk it. */ export const MAX_COMPRESSIBLE_SOURCE_BYTES = 50 * 1024 * 1024; +const MAX_HEIC_DECODE_PIXELS = 64_000_000; +const MAX_HEIC_METADATA_BYTES = 1024 * 1024; /** * Quality ladder tried in order until the encoded image fits the budget. * The floor stays high enough to avoid visible blocking on UI screenshots; @@ -32,6 +35,8 @@ export const MAX_COMPRESSIBLE_SOURCE_BYTES = 50 * 1024 * 1024; const QUALITY_STEPS = [0.92, 0.85, 0.78, 0.68] as const; /** Extra downscale passes applied when even the lowest quality overflows. */ const FALLBACK_SCALE_STEPS = [0.75, 0.55] as const; +const HEIC_IMAGE_MIME_TYPE = /^image\/hei(?:c|f)$/i; +const HEIC_IMAGE_EXTENSION = /\.(?:heic|heif)$/i; export interface CompressedStashImage { dataUrl: string; @@ -55,6 +60,86 @@ export type CompressImageFileResult = | { ok: true; file: File; recompressed: boolean } | { ok: false; reason: ImageCompressionFailureReason }; +/** Finder and some browsers omit the MIME type when dragging HEIC photos. */ +export function isHeicImageFile(file: Pick): boolean { + if (HEIC_IMAGE_MIME_TYPE.test(file.type)) { + return true; + } + return ( + (file.type === "" || file.type.toLowerCase() === "application/octet-stream") && + HEIC_IMAGE_EXTENSION.test(file.name) + ); +} + +interface HeicMetadataBox { + payloadOffset: number; + endOffset: number; +} + +function findHeicMetadataBox( + view: DataView, + startOffset: number, + endOffset: number, + type: number, +): HeicMetadataBox | null { + let offset = startOffset; + while (offset + 8 <= endOffset) { + let size = view.getUint32(offset); + let headerSize = 8; + if (size === 1) { + if (offset + 16 > endOffset) return null; + const extendedSize = view.getBigUint64(offset + 8); + if (extendedSize > BigInt(Number.MAX_SAFE_INTEGER)) return null; + size = Number(extendedSize); + headerSize = 16; + } else if (size === 0) { + size = endOffset - offset; + } + if (size < headerSize || size > endOffset - offset) return null; + + const nextOffset = offset + size; + if (view.getUint32(offset + 4) === type) { + return { payloadOffset: offset + headerSize, endOffset: nextOffset }; + } + offset = nextOffset; + } + return null; +} + +/** Read HEIC image dimensions before the decoder allocates full RGBA buffers. */ +async function validateHeicImageDimensions( + file: File, +): Promise { + const metadata = await file.slice(0, MAX_HEIC_METADATA_BYTES).arrayBuffer(); + const view = new DataView(metadata); + const meta = findHeicMetadataBox(view, 0, view.byteLength, 0x6d657461); + if (!meta || meta.payloadOffset + 4 > meta.endOffset) return "unreadable"; + const properties = findHeicMetadataBox(view, meta.payloadOffset + 4, meta.endOffset, 0x69707270); + if (!properties) return "unreadable"; + const containers = findHeicMetadataBox( + view, + properties.payloadOffset, + properties.endOffset, + 0x6970636f, + ); + if (!containers) return "unreadable"; + + let offset = containers.payloadOffset; + let foundImageDimensions = false; + while (offset < containers.endOffset) { + const image = findHeicMetadataBox(view, offset, containers.endOffset, 0x69737065); + if (!image) break; + if (image.payloadOffset + 12 > image.endOffset) return "unreadable"; + const width = view.getUint32(image.payloadOffset + 4); + const height = view.getUint32(image.payloadOffset + 8); + if (width === 0 || height === 0) return "unreadable"; + if (width > MAX_HEIC_DECODE_PIXELS / height) return "too-large"; + foundImageDimensions = true; + offset = image.endOffset; + } + return foundImageDimensions ? null : "unreadable"; +} + /** Chunked so a large image can't blow the argument limit of `fromCharCode`. */ const BASE64_CHUNK_SIZE = 0x8000; @@ -167,6 +252,7 @@ async function encodeWithinBudget( bitmap: ImageBitmap, maxDimension: number, budgetChars: number, + preferredMimeType?: "image/jpeg", ): Promise<{ dataUrl: string; mimeType: string } | null> { const scale = Math.min(1, maxDimension / Math.max(bitmap.width, bitmap.height)); const width = Math.max(1, Math.round(bitmap.width * scale)); @@ -176,8 +262,11 @@ async function encodeWithinBudget( // Probe WebP once; JPEG (no alpha) needs a white matte, so the fill has to // happen before drawing and depends on which codec we end up using. - const probe = await encodeCanvas(target.canvas, QUALITY_STEPS[0], "image/webp", 0); - const mimeType = probe ? "image/webp" : "image/jpeg"; + const mimeType = + preferredMimeType ?? + ((await encodeCanvas(target.canvas, QUALITY_STEPS[0], "image/webp", 0)) + ? "image/webp" + : "image/jpeg"); if (mimeType === "image/jpeg") { target.context.fillStyle = "#ffffff"; @@ -203,7 +292,11 @@ type ReencodeResult = * Shared re-encode loop: decodes `file`, then walks the quality ladder and * fallback downscale passes until an encoding fits `budgetChars`. */ -async function reencodeWithinBudget(file: File, budgetChars: number): Promise { +async function reencodeWithinBudget( + file: File, + budgetChars: number, + preferredMimeType?: "image/jpeg", +): Promise { if (!canRecompress()) { return { ok: false, reason: "too-large" }; } @@ -229,7 +322,7 @@ async function reencodeWithinBudget(file: File, budgetChars: number): Promise { if (file.size <= maxBytes) { return { ok: true, file, recompressed: false }; } - if (file.size > MAX_COMPRESSIBLE_SOURCE_BYTES) { + if ((options?.sourceSizeBytes ?? file.size) > MAX_COMPRESSIBLE_SOURCE_BYTES) { return { ok: false, reason: "too-large" }; } // The re-encode loop budgets in data-URL characters. Base64 turns 3 bytes // into 4 chars; flooring keeps the budget a hair conservative instead of // admitting an encoding right at the byte cap. const budgetChars = Math.floor(maxBytes / 3) * 4; - const reencoded = await reencodeWithinBudget(file, budgetChars); + const reencoded = await reencodeWithinBudget(file, budgetChars, options?.preferredMimeType); if (!reencoded.ok) { return reencoded; } @@ -330,3 +426,43 @@ export async function compressImageToByteLimit( recompressed: true, }; } + +/** + * Converts HEIC/HEIF photos to provider-compatible JPEG before applying the + * attachment size limit. The decoder is loaded only when such a photo arrives. + */ +export async function prepareImageForAttachment( + file: File, + maxBytes: number, +): Promise { + if (!isHeicImageFile(file)) { + return compressImageToByteLimit(file, maxBytes); + } + + if (file.size > MAX_COMPRESSIBLE_SOURCE_BYTES) { + return { ok: false, reason: "too-large" }; + } + + let converted: Blob; + try { + const dimensionError = await validateHeicImageDimensions(file); + if (dimensionError) { + return { ok: false, reason: dimensionError }; + } + const { heicTo } = await import("heic-to/csp"); + converted = await heicTo({ blob: file, type: "image/jpeg", quality: QUALITY_STEPS[0] }); + } catch { + return { ok: false, reason: "unreadable" }; + } + + const jpeg = new File([converted], fileNameForMimeType(file.name || "image", "image/jpeg"), { + type: "image/jpeg", + lastModified: file.lastModified, + }); + const result = await compressImageToByteLimit(jpeg, maxBytes, { + preferredMimeType: "image/jpeg", + sourceSizeBytes: file.size, + }); + + return result.ok ? { ...result, recompressed: true } : result; +} diff --git a/apps/web/src/lib/openPullRequestLink.test.ts b/apps/web/src/lib/openPullRequestLink.test.ts index edba97fa7d3d..bd8cfe3d72d0 100644 --- a/apps/web/src/lib/openPullRequestLink.test.ts +++ b/apps/web/src/lib/openPullRequestLink.test.ts @@ -3,11 +3,114 @@ import { describe, expect, it, vi } from "vite-plus/test"; import { changeRequestRepositoryUrl, findProjectForChangeRequest, + gitHubPullRequestBrowserUrl, + matchesLinkedPullRequestUrl, openPullRequestLink, parseChangeRequestUrl, PullRequestLinkOpenError, shouldOpenPullRequestExternally, } from "./openPullRequestLink"; +import { ProjectId, type RepositoryIdentity } from "@t3tools/contracts"; + +function repositoryIdentity( + provider: string, + canonicalKey: string, + remoteUrl: string, +): RepositoryIdentity { + return { + canonicalKey, + provider, + locator: { source: "git-remote", remoteName: "origin", remoteUrl }, + }; +} + +describe("gitHubPullRequestBrowserUrl", () => { + it("uses the requested GitHub repository instead of the project's default repository", () => { + const identity = repositoryIdentity( + "github", + "github.com/acme/default", + "https://github.com/acme/default.git", + ); + + expect(gitHubPullRequestBrowserUrl(identity, "acme/other", 42)).toBe( + "https://github.com/acme/other/pull/42", + ); + }); + + it("preserves a custom GitHub HTTP origin without its credentials", () => { + const identity = repositoryIdentity( + "github", + "github.acme.test/team/default", + "http://token@github.acme.test:8443/team/default.git", + ); + + expect(gitHubPullRequestBrowserUrl(identity, "platform/api", 7)).toBe( + "http://github.acme.test:8443/platform/api/pull/7", + ); + }); + + it.each([ + { + name: "SSH", + remoteUrl: "git@github.acme.test:team/default.git", + }, + { + name: "git protocol", + remoteUrl: "git://github.acme.test/team/default.git", + }, + ])("uses the normalized host for a $name remote", ({ remoteUrl }) => { + const identity = repositoryIdentity("github", "github.acme.test/team/default", remoteUrl); + + expect(gitHubPullRequestBrowserUrl(identity, "platform/api", 9)).toBe( + "https://github.acme.test/platform/api/pull/9", + ); + }); + + it("returns null for missing or invalid GitHub data", () => { + expect(gitHubPullRequestBrowserUrl(null, "acme/repository", 1)).toBeNull(); + expect( + gitHubPullRequestBrowserUrl( + repositoryIdentity("github", "github.com/acme/repository", "https://github.com/a/b"), + "acme", + 1, + ), + ).toBeNull(); + expect( + gitHubPullRequestBrowserUrl( + repositoryIdentity("github", "github.com/acme/repository", "https://github.com/a/b"), + "../repository", + 1, + ), + ).toBeNull(); + expect( + gitHubPullRequestBrowserUrl( + repositoryIdentity("github", "github.com/acme/repository", "https://github.com/a/b"), + "acme/repository", + 0, + ), + ).toBeNull(); + expect( + gitHubPullRequestBrowserUrl( + repositoryIdentity("github", "bad host/acme/repository", "not a remote"), + "acme/repository", + 1, + ), + ).toBeNull(); + }); + + it.each(["gitlab", "bitbucket", "azure-devops", "unknown"])( + "does not build a fallback for %s", + (provider) => { + expect( + gitHubPullRequestBrowserUrl( + repositoryIdentity(provider, "github.com/acme/repository", "https://github.com/a/b"), + "acme/repository", + 1, + ), + ).toBeNull(); + }, + ); +}); describe("changeRequestRepositoryUrl", () => { it("preserves repository path casing", () => { @@ -27,6 +130,36 @@ describe("changeRequestRepositoryUrl", () => { }); }); +describe("matchesLinkedPullRequestUrl", () => { + const linkedPullRequest = { + projectId: ProjectId.make("project-1"), + repository: "pingdotgg/t3code", + number: 42, + url: "https://github.com/pingdotgg/t3code/pull/42", + }; + + it("matches the same pull request without looking up its project", () => { + expect( + matchesLinkedPullRequestUrl( + linkedPullRequest, + "https://github.com/PingDotGG/T3Code/pull/42/files", + ), + ).toBe(true); + }); + + it("rejects a different pull request or host", () => { + expect( + matchesLinkedPullRequestUrl(linkedPullRequest, "https://github.com/pingdotgg/t3code/pull/43"), + ).toBe(false); + expect( + matchesLinkedPullRequestUrl( + linkedPullRequest, + "https://github.example.com/pingdotgg/t3code/pull/42", + ), + ).toBe(false); + }); +}); + describe("openPullRequestLink", () => { it("opens the requested pull request URL", async () => { const openExternal = vi.fn(async () => undefined); diff --git a/apps/web/src/lib/openPullRequestLink.ts b/apps/web/src/lib/openPullRequestLink.ts index c8ec1b7a628c..810956c52794 100644 --- a/apps/web/src/lib/openPullRequestLink.ts +++ b/apps/web/src/lib/openPullRequestLink.ts @@ -1,4 +1,10 @@ -import type { EnvironmentId, LocalApi, ScopedThreadRef } from "@t3tools/contracts"; +import type { + EnvironmentId, + LocalApi, + RepositoryIdentity, + ScopedThreadRef, + ThreadLinkedPullRequest, +} from "@t3tools/contracts"; import { useNavigate } from "@tanstack/react-router"; import * as Schema from "effect/Schema"; import { type MouseEvent, useCallback } from "react"; @@ -48,6 +54,42 @@ export async function openPullRequestLink( } } +/** Builds a GitHub URL that remains available when the pull request API cannot be read. */ +export function gitHubPullRequestBrowserUrl( + identity: RepositoryIdentity | null | undefined, + repository: string, + number: number, +): string | null { + if (identity?.provider !== "github" || !Number.isSafeInteger(number) || number < 1) return null; + const repositoryPath = repository.split("/"); + if ( + repositoryPath.length !== 2 || + repositoryPath.some((segment) => segment.length === 0 || segment === "." || segment === "..") + ) { + return null; + } + + let origin: string | null = null; + try { + const remoteUrl = new URL(identity.locator.remoteUrl.trim()); + if (remoteUrl.protocol === "http:" || remoteUrl.protocol === "https:") { + origin = remoteUrl.origin; + } + } catch { + // SCP-style remotes are read from their normalized identity below. + } + const hostname = identity.canonicalKey.split("/")[0]; + if (origin === null && !hostname) return null; + + try { + const url = new URL(origin ?? `https://${hostname}`); + url.pathname = `/${repositoryPath.join("/")}/pull/${number}`; + return url.toString(); + } catch { + return null; + } +} + /** * A change request the page can open, named the way the page names one: the host below which the * repository is addressed, the repository path as that host writes it, and the number. @@ -118,6 +160,22 @@ export function parseChangeRequestUrl(targetUrl: string): ChangeRequestLink | nu return null; } +/** Match a stored PR without requiring its project to remain available. */ +export function matchesLinkedPullRequestUrl( + linkedPullRequest: ThreadLinkedPullRequest, + targetUrl: string, +): boolean { + const linked = parseChangeRequestUrl(linkedPullRequest.url); + const target = parseChangeRequestUrl(targetUrl); + return ( + linked !== null && + target !== null && + linked.host === target.host && + linked.repository === target.repository && + linked.number === target.number + ); +} + /** The repository root behind a recognised change-request URL, without PR-specific state. */ export function changeRequestRepositoryUrl(targetUrl: string): string | null { const changeRequest = parseChangeRequestUrl(targetUrl); diff --git a/apps/web/src/lib/terminalUiStateCleanup.ts b/apps/web/src/lib/terminalUiStateCleanup.ts deleted file mode 100644 index 8535f29f1d0e..000000000000 --- a/apps/web/src/lib/terminalUiStateCleanup.ts +++ /dev/null @@ -1,33 +0,0 @@ -interface TerminalUiRetentionThread { - key: string; - deletedAt: string | null; - archivedAt: string | null; -} - -interface CollectActiveTerminalUiThreadKeysInput { - snapshotThreads: readonly TerminalUiRetentionThread[]; - draftThreadKeys: Iterable; -} - -export function collectActiveTerminalUiThreadKeys( - input: CollectActiveTerminalUiThreadKeysInput, -): Set { - const activeThreadKeys = new Set(); - const snapshotThreadById = new Map(input.snapshotThreads.map((thread) => [thread.key, thread])); - for (const thread of input.snapshotThreads) { - if (thread.deletedAt !== null) continue; - if (thread.archivedAt !== null) continue; - activeThreadKeys.add(thread.key); - } - for (const draftThreadKey of input.draftThreadKeys) { - const snapshotThread = snapshotThreadById.get(draftThreadKey); - if ( - snapshotThread && - (snapshotThread.deletedAt !== null || snapshotThread.archivedAt !== null) - ) { - continue; - } - activeThreadKeys.add(draftThreadKey); - } - return activeThreadKeys; -} diff --git a/apps/web/src/lib/threadSort.ts b/apps/web/src/lib/threadSort.ts index ac3dea3aca5d..53438305c321 100644 --- a/apps/web/src/lib/threadSort.ts +++ b/apps/web/src/lib/threadSort.ts @@ -1,4 +1,5 @@ export { + activeThreadAnchorTimestampMs, getLatestThreadForProject, getThreadSortTimestamp, sortThreads, diff --git a/apps/web/src/lib/utils.ts b/apps/web/src/lib/utils.ts index 07966d015d03..d96021aeb5e0 100644 --- a/apps/web/src/lib/utils.ts +++ b/apps/web/src/lib/utils.ts @@ -20,6 +20,10 @@ export function isLinuxPlatform(platform: string): boolean { return /linux/i.test(platform); } +export function normalizeSearchText(value: string): string { + return value.normalize("NFKD").replace(/\p{M}/gu, "").toLowerCase().replace(/\s+/g, " ").trim(); +} + export function getLocalFileManagerName(platform: string): string { if (isMacPlatform(platform)) { return "Finder"; diff --git a/apps/web/src/lib/videoFirstFrame.test.ts b/apps/web/src/lib/videoFirstFrame.test.ts new file mode 100644 index 000000000000..f090a503c386 --- /dev/null +++ b/apps/web/src/lib/videoFirstFrame.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { prepareVideoFirstFrame } from "./videoFirstFrame"; + +type PreviewVideo = Parameters[0]; + +function previewVideo(overrides: Partial = {}): PreviewVideo { + return { + autoplay: false, + paused: true, + seeking: false, + currentTime: 0, + duration: 5, + played: { length: 0, start: () => 0, end: () => 0 }, + src: "https://environment.test/api/assets/signed/video.mp4?signature=example", + ...overrides, + }; +} + +describe("prepareVideoFirstFrame", () => { + it.each([ + [5, 0.1], + [0.05, 0.025], + ])( + "seeks within a %s second video only once when metadata repeats", + (duration, expectedPosition) => { + const video = previewVideo({ duration }); + const seeks: number[] = []; + Object.defineProperty(video, "currentTime", { + get: () => seeks.at(-1) ?? 0, + set: (value: number) => seeks.push(value), + }); + prepareVideoFirstFrame(video); + prepareVideoFirstFrame(video); + + expect(seeks).toEqual([expectedPosition]); + }, + ); + + it.each>([ + { autoplay: true }, + { paused: false }, + { seeking: true }, + { currentTime: 2 }, + { played: { length: 1, start: () => 0, end: () => 2 } }, + { duration: 0 }, + { duration: Number.POSITIVE_INFINITY }, + ])("does not seek over playback or unavailable metadata: %j", (state) => { + const video = previewVideo(state); + const position = video.currentTime; + + prepareVideoFirstFrame(video); + + expect(video.currentTime).toBe(position); + }); + + it.each([ + ["video.mp4#t=0,4", 0], + ["video.mp4#xywh=0,0,100,100&%74=3", 0], + ["video%23t=3.mp4", 0.1], + ])( + "distinguishes temporal fragments from encoded filename hashes: %s", + (path, expectedPosition) => { + const video = previewVideo({ src: `https://environment.test/${path}` }); + + prepareVideoFirstFrame(video); + + expect(video.currentTime).toBe(expectedPosition); + }, + ); + + it("tolerates a browser rejecting the preview seek", () => { + const video = previewVideo(); + Object.defineProperty(video, "currentTime", { + get: () => 0, + set: () => { + throw new Error("The stream is not seekable yet"); + }, + }); + + expect(() => prepareVideoFirstFrame(video)).not.toThrow(); + }); +}); diff --git a/apps/web/src/lib/videoFirstFrame.ts b/apps/web/src/lib/videoFirstFrame.ts new file mode 100644 index 000000000000..07acdbbc9ebf --- /dev/null +++ b/apps/web/src/lib/videoFirstFrame.ts @@ -0,0 +1,28 @@ +/** Requests an initial frame without playing or replacing the video's streaming source. */ +export function prepareVideoFirstFrame( + video: Pick< + HTMLVideoElement, + "autoplay" | "currentTime" | "duration" | "paused" | "played" | "seeking" | "src" + >, +): void { + if ( + video.autoplay || + !video.paused || + video.seeking || + video.currentTime !== 0 || + video.played.length > 0 || + !Number.isFinite(video.duration) || + video.duration <= 0 + ) { + return; + } + + const fragment = video.src.split("#", 2)[1]; + if (fragment && new URLSearchParams(fragment).has("t")) return; + + try { + video.currentTime = Math.min(0.1, video.duration / 2); + } catch { + // A rejected preview seek must leave the native Play control usable. + } +} diff --git a/apps/web/src/localApi.ts b/apps/web/src/localApi.ts index 863388106a3e..8f55f65e40de 100644 --- a/apps/web/src/localApi.ts +++ b/apps/web/src/localApi.ts @@ -3,7 +3,6 @@ import type { ConfirmDialogOptions, ContextMenuItem, LocalApi } from "@t3tools/c import { requestConfirmDialog } from "./confirmDialog"; import { dismissContextMenu, showContextMenuFallback } from "./contextMenuFallback"; import { readBrowserClientSettings, writeBrowserClientSettings } from "./clientPersistenceStorage"; -import { resetRequestLatencyStateForTests } from "./rpc/requestLatencyState"; let cachedApi: LocalApi | undefined; @@ -86,10 +85,3 @@ export function ensureLocalApi(): LocalApi { } return api; } - -export async function __resetLocalApiForTests() { - cachedApi = undefined; - const { __resetClientSettingsPersistenceForTests } = await import("./hooks/useSettings"); - __resetClientSettingsPersistenceForTests(); - resetRequestLatencyStateForTests(); -} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 6eaaca6f57de..3cdc8188b757 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -30,11 +30,6 @@ if (isElectron) { const clerkPublishableKey = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY as string | undefined; -// First Clerk UI build containing https://github.com/clerk/javascript/pull/9500. -const electronClerkUI = { - __internal_clerkUIVersion: "1.30.5-canary.v20260819050620", -}; - const app = ; ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( @@ -42,7 +37,6 @@ ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( {clerkPublishableKey && hasCloudPublicConfig() ? ( isElectron ? ( = [], + private readonly attributes: Readonly> = {}, ) {} get localName(): string { @@ -37,12 +38,12 @@ class FakeElement { return this; } - getAttribute(): string | null { - return null; + getAttribute(name: string): string | null { + return this.attributes[name] ?? null; } - hasAttribute(): boolean { - return false; + hasAttribute(name: string): boolean { + return Object.hasOwn(this.attributes, name); } } @@ -92,4 +93,19 @@ describe("serializeRenderedMarkdownFragment", () => { expect(serializeRenderedMarkdownFragment(asNode(container))).toBe("first line\nsecond line"); }); + + it("uses a rendered card's explicit Markdown copy representation", () => { + const card = new FakeElement("DIV", [], { + "data-markdown-copy": "Hello World (Document template)\n\n", + }).append( + new FakeElement("SPAN").append(new FakeText("Hello World")), + new FakeElement("SPAN").append(new FakeText("Document template")), + new FakeElement("BUTTON").append(new FakeText("Use template")), + ); + const container = new FakeElement("DIV").append(card); + + expect(serializeRenderedMarkdownFragment(asNode(container))).toBe( + "Hello World (Document template)", + ); + }); }); diff --git a/apps/web/src/markdown-links.test.ts b/apps/web/src/markdown-links.test.ts index a1d1094bb8d1..fc77f48c2ca6 100644 --- a/apps/web/src/markdown-links.test.ts +++ b/apps/web/src/markdown-links.test.ts @@ -5,13 +5,25 @@ import ReactMarkdown from "react-markdown"; import { extractMarkdownLinkHrefs, + isWindowsDrivePathHref, resolveInlineCodeFileLinkMeta, resolveMarkdownFileLinkMeta, resolveMarkdownFileLinkTarget, rewriteMarkdownFileUriHref, + shouldOpenMarkdownFileLinkInBrowserByDefault, shouldOpenMarkdownFileLinkInEditor, } from "./markdown-links"; +describe("isWindowsDrivePathHref", () => { + it.each([ + ["C:\\repo\\image.png", true], + ["C:%5Crepo%5Cimage.png", true], + ["https://example.com/image.png", false], + ])("classifies %s as %s", (href, expected) => { + expect(isWindowsDrivePathHref(href)).toBe(expected); + }); +}); + function renderMarkdownLinkHref(markdown: string): string | undefined { let renderedHref: string | undefined; renderToStaticMarkup( @@ -69,6 +81,15 @@ describe("shouldOpenMarkdownFileLinkInEditor", () => { }); }); +describe("shouldOpenMarkdownFileLinkInBrowserByDefault", () => { + it("keeps PDFs browser-first while source files open in the file viewer", () => { + expect(shouldOpenMarkdownFileLinkInBrowserByDefault("report.pdf")).toBe(true); + expect(shouldOpenMarkdownFileLinkInBrowserByDefault("report.PDF?download=1")).toBe(true); + expect(shouldOpenMarkdownFileLinkInBrowserByDefault("report.html")).toBe(false); + expect(shouldOpenMarkdownFileLinkInBrowserByDefault("report.xml")).toBe(false); + }); +}); + describe("rewriteMarkdownFileUriHref", () => { it("rewrites file uri hrefs into direct path hrefs", () => { expect(rewriteMarkdownFileUriHref("file:///Users/julius/project/src/main.ts#L42")).toBe( @@ -142,6 +163,7 @@ describe("resolveMarkdownFileLinkTarget", () => { it("ignores external urls", () => { expect(resolveMarkdownFileLinkTarget("https://example.com/docs")).toBeNull(); + expect(resolveMarkdownFileLinkTarget("//cdn.example.com/clip.mp4", "/workspace")).toBeNull(); }); it("does not double-decode file URLs", () => { @@ -198,6 +220,20 @@ describe("resolveMarkdownFileLinkTarget", () => { }); }); + it.each(["md", "html", "xml"])( + "resolves a bare spaced .%s filename from the markdown renderer", + (extension) => { + const href = renderMarkdownLinkHref(`[checklist]()`); + + expect(href).toBe(`Updated%20cutover%20checklist.${extension}`); + expect(resolveMarkdownFileLinkMeta(href, "/repo/project")).toMatchObject({ + targetPath: `/repo/project/Updated cutover checklist.${extension}`, + workspaceRelativePath: `Updated cutover checklist.${extension}`, + basename: `Updated cutover checklist.${extension}`, + }); + }, + ); + it("formats tooltip display paths relative to the cwd for slash-prefixed windows paths", () => { expect( resolveMarkdownFileLinkMeta( diff --git a/apps/web/src/markdown-links.ts b/apps/web/src/markdown-links.ts index 6ba2c78e13fb..7ea965e71bbc 100644 --- a/apps/web/src/markdown-links.ts +++ b/apps/web/src/markdown-links.ts @@ -1,3 +1,8 @@ +import { + inlineCodeFilePathCandidate, + isConventionalFilePosition, +} from "@t3tools/client-runtime/markdown-links"; + import { formatWorkspaceRelativePath } from "./filePathDisplay"; import { isTerminalLinkActivation, @@ -11,7 +16,8 @@ const EXTERNAL_SCHEME_PATTERN = /^([A-Za-z][A-Za-z0-9+.-]*):(.*)$/; const RELATIVE_PATH_PREFIX_PATTERN = /^(~\/|\.{1,2}\/)/; const RELATIVE_FILE_PATH_PATTERN = /^(?:[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*\/)+[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*(?::\d+){0,2}$/; -const RELATIVE_FILE_NAME_PATTERN = /^[A-Za-z0-9._-]+\.[A-Za-z0-9_-]+(?::\d+){0,2}$/; +const RELATIVE_FILE_NAME_PATTERN = + /^[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*\.[A-Za-z0-9_-]+(?::\d+){0,2}$/; const POSITION_SUFFIX_PATTERN = /:\d+(?::\d+)?$/; const POSITION_ONLY_PATTERN = /^\d+(?::\d+)?$/; // Standard OS and dev-container roots; deliberately excludes app-route-ish @@ -71,6 +77,10 @@ export function shouldOpenMarkdownFileLinkInEditor( return isTerminalLinkActivation(event, platform); } +export function shouldOpenMarkdownFileLinkInBrowserByDefault(path: string): boolean { + return /\.pdf$/i.test(path.split(/[?#]/, 1)[0] ?? ""); +} + function safeDecode(value: string): string { try { return decodeURIComponent(value); @@ -79,6 +89,10 @@ function safeDecode(value: string): string { } } +export function isWindowsDrivePathHref(href: string): boolean { + return WINDOWS_DRIVE_PATH_PATTERN.test(safeDecode(href)); +} + function unwrapMarkdownLinkDestination(value: string): string { return value.startsWith("<") && value.endsWith(">") ? value.slice(1, -1) : value; } @@ -181,7 +195,7 @@ export function resolveMarkdownFileLinkTarget( ): string | null { if (!href) return null; const rawHref = normalizeMarkdownLinkDestination(href); - if (rawHref.length === 0 || rawHref.startsWith("#")) return null; + if (rawHref.length === 0 || rawHref.startsWith("#") || rawHref.startsWith("//")) return null; const fileUrlTarget = rawHref.toLowerCase().startsWith("file:") ? parseFileUrlHref(rawHref) @@ -212,124 +226,6 @@ export function resolveMarkdownFileLinkTarget( return resolvePathLinkTarget(pathWithPosition, cwd); } -const INLINE_CODE_DISQUALIFIER_PATTERN = /[\s`]/; -const PATH_SEPARATOR_PATTERN = /[\\/]/; -const FILE_EXTENSION_PATTERN = /\.[A-Za-z0-9_-]+$/; -const NUMERIC_DOTTED_PATTERN = /^\d+(?:\.\d+)+$/; -const BARE_EXTENSIONLESS_POSITION_PATTERN = /^[A-Za-z0-9_-]+(?::\d+){1,2}$/; -// Any `Name:digits` shape also matches `error:1`, `port:3000`, `TODO:12`, so -// extensionless linking is limited to conventional filenames. -const EXTENSIONLESS_FILE_NAMES = new Set([ - "Makefile", - "makefile", - "GNUmakefile", - "Dockerfile", - "Containerfile", - "Justfile", - "justfile", - "Rakefile", - "Gemfile", - "Procfile", - "Brewfile", - "Caddyfile", - "Vagrantfile", - "Jenkinsfile", - "Podfile", - "Fastfile", - "BUILD", - "WORKSPACE", - "LICENSE", - "LICENCE", - "COPYING", - "NOTICE", - "AUTHORS", - "CONTRIBUTORS", - "CHANGELOG", - "README", - "CODEOWNERS", -]); -const SINGLE_LABEL_HOSTNAMES = new Set(["localhost"]); -// Allowlists, not full public-suffix detection: treating every dotted first -// segment as a host would swallow real paths like `conf.d/x.conf` or -// `Makefile.in:12`. Extensions that double as filename suffixes (`sh`, `md`, -// `ts`, `rs`, `in`, ...) are deliberately absent from both sets. -const GENERIC_HOSTNAME_TLDS = new Set([ - "com", - "net", - "org", - "io", - "dev", - "app", - "ai", - "co", - "edu", - "gov", - "mil", - "info", - "biz", - "xyz", - "me", - "tv", - "cc", - "gg", - "chat", - "cloud", - "site", - "online", - "tech", - "store", - "link", -]); -// Country codes collide with file extensions (`.pl` Perl, `.pt` PyTorch, -// `.es` ES modules), so they only count as host evidence when the candidate -// lacks a :line suffix — an explicit line reference marks a file and wins. -const COUNTRY_HOSTNAME_TLDS = new Set([ - "uk", - "de", - "fr", - "nl", - "se", - "no", - "fi", - "dk", - "pl", - "ch", - "at", - "be", - "es", - "it", - "pt", - "eu", - "us", - "ca", - "au", - "nz", - "jp", - "kr", - "cn", - "br", - "ru", - "mx", - "ie", - "cz", - "tr", - "sg", - "hk", -]); - -/** `127.0.0.1`, `localhost`, `example.com`, `1.2.3` — hosts and versions, not files. */ -function looksLikeHostname(segment: string, hasPosition: boolean): boolean { - if (segment.startsWith(".")) return false; - const lowered = segment.toLowerCase(); - if (SINGLE_LABEL_HOSTNAMES.has(lowered)) return true; - if (NUMERIC_DOTTED_PATTERN.test(segment)) return true; - const labels = lowered.split("."); - const lastLabel = labels[labels.length - 1]; - if (labels.length < 2 || lastLabel === undefined) return false; - if (GENERIC_HOSTNAME_TLDS.has(lastLabel)) return true; - return !hasPosition && COUNTRY_HOSTNAME_TLDS.has(lastLabel); -} - /** * Inline code spans mostly hold identifiers, commands, and refs (`node.meta`, * `origin/main`) rather than deliberate link destinations, so auto-linking @@ -340,33 +236,8 @@ export function resolveInlineCodeFileLinkMeta( codeText: string, cwd?: string, ): MarkdownFileLinkMeta | null { - const trimmed = codeText.trim(); - if (trimmed.length === 0 || INLINE_CODE_DISQUALIFIER_PATTERN.test(trimmed)) return null; - - // Windows drive/UNC paths keep their backslashes; any other backslashes are - // relative Windows-style paths, which neither the shape checks nor the - // downstream resolver understand — normalize them to forward slashes. - const candidate = - WINDOWS_DRIVE_PATH_PATTERN.test(trimmed) || WINDOWS_UNC_PATH_PATTERN.test(trimmed) - ? trimmed - : trimmed.replaceAll("\\", "/"); - - const hasPosition = POSITION_SUFFIX_PATTERN.test(candidate); - if (!hasPosition && !PATH_SEPARATOR_PATTERN.test(candidate)) return null; - - const hasExplicitPathShape = - RELATIVE_PATH_PREFIX_PATTERN.test(candidate) || - candidate.startsWith("/") || - WINDOWS_DRIVE_PATH_PATTERN.test(candidate) || - WINDOWS_UNC_PATH_PATTERN.test(candidate); - if (!hasExplicitPathShape) { - const withoutPosition = candidate.replace(POSITION_SUFFIX_PATTERN, ""); - const firstSegment = withoutPosition.split("/")[0] ?? withoutPosition; - if (looksLikeHostname(firstSegment, hasPosition)) return null; - if (!hasPosition && !FILE_EXTENSION_PATTERN.test(basenameOfPath(withoutPosition))) { - return null; - } - } + const candidate = inlineCodeFilePathCandidate(codeText); + if (candidate === null) return null; const resolved = resolveMarkdownFileLinkMeta(candidate, cwd); if (resolved) return resolved; @@ -374,11 +245,7 @@ export function resolveInlineCodeFileLinkMeta( // `Makefile:12` — conventional extensionless names fail the generic // markdown-link candidate patterns, but here the :line suffix already // marked the span as a file reference. - if ( - cwd && - BARE_EXTENSIONLESS_POSITION_PATTERN.test(candidate) && - EXTENSIONLESS_FILE_NAMES.has(candidate.replace(POSITION_SUFFIX_PATTERN, "")) - ) { + if (cwd && isConventionalFilePosition(candidate)) { return buildFileLinkMetaFromTarget(resolvePathLinkTarget(candidate, cwd), cwd); } return null; diff --git a/apps/web/src/modelSelection.test.ts b/apps/web/src/modelSelection.test.ts index 405366d9fcbe..ce2eea0f2312 100644 --- a/apps/web/src/modelSelection.test.ts +++ b/apps/web/src/modelSelection.test.ts @@ -2,8 +2,11 @@ import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3 import { DEFAULT_UNIFIED_SETTINGS, type UnifiedSettings } from "@t3tools/contracts/settings"; import { describe, expect, it } from "vite-plus/test"; import { createModelSelection } from "@t3tools/shared/model"; +import { deriveEffectiveComposerModelState } from "./composerDraftStore"; +import { getComposerProviderState } from "./components/chat/composerProviderState"; import { deriveProviderInstanceEntries } from "./providerInstances"; import { + getCustomModelOptionsByInstance, getAppModelOptionsForInstance, resolveAppModelSelectionForInstance, resolveAppModelSelectionState, @@ -275,6 +278,15 @@ describe("instance-scoped model selection", () => { "claude-opus-4-6", ), ).toBe("claude-sonnet-4-6"); + expect( + resolveAppModelSelectionForInstance( + ProviderInstanceId.make("claudeAgent"), + settings, + providers, + "claude-opus-4-6", + { preserveUnavailableSelection: true }, + ), + ).toBe("claude-sonnet-4-6"); }); it("falls back instead of resolving a custom slug against the wrong instance", () => { @@ -299,6 +311,287 @@ describe("instance-scoped model selection", () => { ).toBe("claude-sonnet-4-6"); }); + it("preserves an existing OpenCode model when a catalog refresh no longer contains it", () => { + const providers = [ + provider({ + provider: ProviderDriverKind.make("opencode"), + instanceId: "opencode", + models: ["opencode/big-pickle"], + }), + ]; + + expect( + resolveAppModelSelectionForInstance( + ProviderInstanceId.make("opencode"), + settingsWithProviderInstances(), + providers, + "opencode/kimi-k3", + { preserveUnavailableSelection: true }, + ), + ).toBe("opencode/kimi-k3"); + expect( + resolveAppModelSelectionForInstance( + ProviderInstanceId.make("opencode"), + settingsWithProviderInstances(), + providers, + "opencode/kimi-k3", + ), + ).toBe("opencode/big-pickle"); + }); + + it("adds the selected missing OpenCode model as an unavailable option", () => { + const providers = [ + provider({ + provider: ProviderDriverKind.make("opencode"), + instanceId: "opencode", + models: ["opencode/big-pickle"], + }), + ]; + const entry = deriveProviderInstanceEntries(providers)[0]!; + + expect( + getAppModelOptionsForInstance(settingsWithProviderInstances(), entry, "opencode/kimi-k3"), + ).toEqual([ + expect.objectContaining({ slug: "opencode/big-pickle" }), + expect.objectContaining({ + slug: "opencode/kimi-k3", + name: "opencode/kimi-k3", + isUnavailable: true, + }), + ]); + }); + + it("keeps a missing OpenCode option scoped to the selected instance", () => { + const selectedInstanceId = ProviderInstanceId.make("opencode_work"); + const otherInstanceId = ProviderInstanceId.make("opencode_personal"); + const driver = ProviderDriverKind.make("opencode"); + const providers = [ + provider({ provider: driver, instanceId: selectedInstanceId, models: [] }), + provider({ provider: driver, instanceId: otherInstanceId, models: [] }), + ]; + const options = getCustomModelOptionsByInstance( + settingsWithProviderInstances(), + providers, + selectedInstanceId, + "openrouter/kimi-k3", + ); + + expect(options.get(selectedInstanceId)).toEqual([ + expect.objectContaining({ slug: "openrouter/kimi-k3", isUnavailable: true }), + ]); + expect(options.get(otherInstanceId)).toEqual([]); + }); + + it("replaces the unavailable marker with catalog metadata after recovery", () => { + const instanceId = ProviderInstanceId.make("opencode"); + const driver = ProviderDriverKind.make("opencode"); + const selectedModel = "opencode/kimi-k3"; + const pendingProviders = [ + provider({ provider: driver, instanceId, models: ["opencode/big-pickle"] }), + ]; + const recoveredProviders = [ + provider({ provider: driver, instanceId, models: ["opencode/big-pickle", selectedModel] }), + ]; + + expect( + getAppModelOptionsForInstance( + settingsWithProviderInstances(), + deriveProviderInstanceEntries(pendingProviders)[0]!, + selectedModel, + ).find((option) => option.slug === selectedModel)?.isUnavailable, + ).toBe(true); + expect( + getAppModelOptionsForInstance( + settingsWithProviderInstances(), + deriveProviderInstanceEntries(recoveredProviders)[0]!, + selectedModel, + ).find((option) => option.slug === selectedModel)?.isUnavailable, + ).toBeUndefined(); + expect( + resolveAppModelSelectionForInstance( + instanceId, + settingsWithProviderInstances(), + recoveredProviders, + selectedModel, + { preserveUnavailableSelection: true }, + ), + ).toBe(selectedModel); + }); + + it("does not resurrect a hidden OpenCode model when the raw catalog omits it", () => { + const instanceId = ProviderInstanceId.make("opencode"); + const driver = ProviderDriverKind.make("opencode"); + const settings: UnifiedSettings = { + ...settingsWithProviderInstances(), + providerModelPreferences: { + [instanceId]: { + hiddenModels: ["opencode/kimi-k3"], + modelOrder: [], + }, + }, + }; + const providers = [provider({ provider: driver, instanceId, models: [] })]; + const entry = deriveProviderInstanceEntries(providers)[0]!; + + expect(getAppModelOptionsForInstance(settings, entry, "opencode/kimi-k3")).toEqual([]); + expect( + resolveAppModelSelectionForInstance(instanceId, settings, providers, "opencode/kimi-k3", { + preserveUnavailableSelection: true, + }), + ).toBeNull(); + }); + + it("does not add unavailable options for other providers", () => { + const providers = [ + provider({ + provider: ProviderDriverKind.make("codex"), + instanceId: "codex", + models: ["gpt-5.6-sol"], + }), + ]; + const entry = deriveProviderInstanceEntries(providers)[0]!; + + expect( + getAppModelOptionsForInstance(settingsWithProviderInstances(), entry, "gpt-missing").map( + (option) => option.slug, + ), + ).toEqual(["gpt-5.6-sol"]); + expect( + resolveAppModelSelectionForInstance( + ProviderInstanceId.make("codex"), + settingsWithProviderInstances(), + providers, + "gpt-missing", + { preserveUnavailableSelection: true }, + ), + ).toBe("gpt-5.6-sol"); + }); + + it("falls back from an explicit non-OpenCode draft with a missing model", () => { + const instanceId = ProviderInstanceId.make("codex"); + const driver = ProviderDriverKind.make("codex"); + const providers = [provider({ provider: driver, instanceId, models: ["gpt-5.6-sol"] })]; + const state = deriveEffectiveComposerModelState({ + draft: { + activeProvider: instanceId, + modelSelectionByProvider: { + [instanceId]: createModelSelection(instanceId, "gpt-missing", [ + { id: "effort", value: "max" }, + ]), + }, + }, + providers, + selectedProvider: driver, + selectedInstanceId: instanceId, + threadModelSelection: null, + projectModelSelection: null, + settings: settingsWithProviderInstances(), + }); + const dispatch = getComposerProviderState({ + provider: driver, + model: state.selectedModel, + models: providers[0]!.models, + modelOptions: state.modelOptions?.[instanceId], + planModeEnabled: false, + }); + + expect(state.selectedModel).toBe("gpt-5.6-sol"); + expect(dispatch.modelOptionsForDispatch).toBeUndefined(); + }); + + it("preserves an explicit draft OpenCode selection while the catalog is empty", () => { + const instanceId = ProviderInstanceId.make("opencode_work"); + const driver = ProviderDriverKind.make("opencode"); + const draftSelection = createModelSelection(instanceId, "openrouter/kimi-k3", [ + { id: "variant", value: "max" }, + { id: "agent", value: "build" }, + ]); + const providers = [provider({ provider: driver, instanceId, models: [] })]; + const state = deriveEffectiveComposerModelState({ + draft: { + activeProvider: instanceId, + modelSelectionByProvider: { [instanceId]: draftSelection }, + }, + providers, + selectedProvider: driver, + selectedInstanceId: instanceId, + threadModelSelection: null, + projectModelSelection: null, + settings: settingsWithProviderInstances(), + }); + + expect(state.selectedModel).toBe("openrouter/kimi-k3"); + expect(state.modelOptions?.[instanceId]).toEqual(draftSelection.options); + }); + + it("preserves saved options through dispatch when the model is absent from the catalog", () => { + const instanceId = ProviderInstanceId.make("opencode"); + const driver = ProviderDriverKind.make("opencode"); + const providers = [provider({ provider: driver, instanceId, models: ["opencode/big-pickle"] })]; + const saved = createModelSelection(instanceId, "opencode/kimi-k3", [ + { id: "variant", value: "max" }, + { id: "agent", value: "build" }, + ]); + const state = deriveEffectiveComposerModelState({ + draft: null, + providers, + selectedProvider: driver, + selectedInstanceId: instanceId, + threadModelSelection: saved, + projectModelSelection: null, + settings: settingsWithProviderInstances(), + }); + const dispatch = getComposerProviderState({ + provider: driver, + model: state.selectedModel, + models: providers[0]!.models, + modelOptions: state.modelOptions?.[instanceId], + planModeEnabled: false, + }); + + expect( + createModelSelection(instanceId, state.selectedModel, dispatch.modelOptionsForDispatch), + ).toEqual(saved); + }); + + it("keeps a custom-instance draft model while dropping unsupported options", () => { + const instanceId = ProviderInstanceId.make("claude_openrouter"); + const driver = ProviderDriverKind.make("claudeAgent"); + const providers = [ + provider({ provider: driver, instanceId: "claudeAgent", models: ["claude-opus-5"] }), + provider({ provider: driver, instanceId, models: ["claude-opus-5"] }), + ]; + const threadSelection = createModelSelection(instanceId, "claude-opus-5", [ + { id: "effort", value: "high" }, + ]); + const draftSelection = createModelSelection(instanceId, "openai/gpt-5.5", [ + { id: "effort", value: "max" }, + ]); + const state = deriveEffectiveComposerModelState({ + draft: { + activeProvider: instanceId, + modelSelectionByProvider: { [instanceId]: draftSelection }, + }, + providers, + selectedProvider: driver, + selectedInstanceId: instanceId, + threadModelSelection: threadSelection, + projectModelSelection: null, + settings: settingsWithProviderInstances(), + }); + const dispatch = getComposerProviderState({ + provider: driver, + model: state.selectedModel, + models: providers[1]!.models, + modelOptions: state.modelOptions?.[instanceId], + planModeEnabled: false, + }); + + expect( + createModelSelection(instanceId, state.selectedModel, dispatch.modelOptionsForDispatch), + ).toEqual(createModelSelection(instanceId, "openai/gpt-5.5")); + }); + it("preserves custom provider instances in settings model selection", () => { const providers = [ provider({ diff --git a/apps/web/src/modelSelection.ts b/apps/web/src/modelSelection.ts index ccdffdda1004..12865d64a9b2 100644 --- a/apps/web/src/modelSelection.ts +++ b/apps/web/src/modelSelection.ts @@ -75,9 +75,32 @@ export interface AppModelOption { name: string; shortName?: string; subProvider?: string; + aliases?: ReadonlyArray; + badge?: "new"; isCustom: boolean; isDefault?: boolean; isLegacy?: boolean; + isUnavailable?: boolean; +} + +function appendUnavailableOpenCodeSelection( + options: AppModelOption[], + rawModels: ReadonlyArray, + provider: ProviderDriverKind, + selectedModel: string | null | undefined, + hiddenModels: ReadonlyArray, +): AppModelOption[] { + if (provider !== "opencode") return options; + const slug = normalizeCustomModelSlug(selectedModel); + if (!slug) return options; + + // A model that exists in the raw catalog can be absent from `options` + // because the user hid it. Keep that preference authoritative. + if (rawModels.some((model) => model.slug === slug)) return options; + if (hiddenModels.includes(slug)) return options; + if (options.some((option) => option.slug === slug)) return options; + + return [...options, { slug, name: slug, isCustom: false, isUnavailable: true }]; } function toAppModelOption(model: ServerProvider["models"][number]): AppModelOption { @@ -88,6 +111,8 @@ function toAppModelOption(model: ServerProvider["models"][number]): AppModelOpti }; if (model.shortName) option.shortName = model.shortName; if (model.subProvider) option.subProvider = model.subProvider; + if (model.aliases) option.aliases = model.aliases; + if (model.badge) option.badge = model.badge; if (model.isDefault) option.isDefault = true; if (model.isLegacy) option.isLegacy = true; return option; @@ -151,9 +176,10 @@ export function getAppModelOptions( settings: UnifiedSettings, providers: ReadonlyArray, provider: ProviderDriverKind, - _selectedModel?: string | null, + selectedModel?: string | null, ): AppModelOption[] { - const options: AppModelOption[] = getProviderModels(providers, provider).map(toAppModelOption); + const rawModels = getProviderModels(providers, provider); + const options: AppModelOption[] = rawModels.map(toAppModelOption); const seen = new Set(options.map((option) => option.slug)); const builtInModelSlugs = new Set( Arr.filterMap(getProviderModels(providers, provider), (model) => @@ -180,9 +206,13 @@ export function getAppModelOptions( }); } - return applyInstanceModelPreferences( - options, - readInstanceModelPreferences(settings, defaultInstanceId), + const preferences = readInstanceModelPreferences(settings, defaultInstanceId); + return appendUnavailableOpenCodeSelection( + applyInstanceModelPreferences(options, preferences), + rawModels, + provider, + selectedModel, + preferences.hiddenModels, ); } @@ -200,6 +230,7 @@ export function getAppModelOptions( export function getAppModelOptionsForInstance( settings: UnifiedSettings, entry: ProviderInstanceEntry, + selectedModel?: string | null, ): AppModelOption[] { const options: AppModelOption[] = entry.models.map(toAppModelOption); const seen = new Set(options.map((option) => option.slug)); @@ -219,9 +250,13 @@ export function getAppModelOptionsForInstance( options.push({ slug, name: slug, isCustom: true }); } - return applyInstanceModelPreferences( - options, - readInstanceModelPreferences(settings, entry.instanceId), + const preferences = readInstanceModelPreferences(settings, entry.instanceId); + return appendUnavailableOpenCodeSelection( + applyInstanceModelPreferences(options, preferences), + entry.models, + entry.driverKind, + selectedModel, + preferences.hiddenModels, ); } @@ -244,14 +279,29 @@ export function resolveAppModelSelectionForInstance( settings: UnifiedSettings, providers: ReadonlyArray, selectedModel: string | null | undefined, + resolutionOptions?: { readonly preserveUnavailableSelection?: boolean }, ): string | null { const entry = deriveProviderInstanceEntries(providers).find( (candidate) => candidate.instanceId === instanceId, ); if (!entry) return null; - const options = getAppModelOptionsForInstance(settings, entry); + const options = getAppModelOptionsForInstance( + settings, + entry, + resolutionOptions?.preserveUnavailableSelection ? selectedModel : null, + ); + const resolvedSelection = resolveSelectableModel(entry.driverKind, selectedModel, options); + if (resolvedSelection) { + return resolvedSelection; + } + if (resolutionOptions?.preserveUnavailableSelection && entry.driverKind === "opencode") { + const unavailableSelection = normalizeCustomModelSlug(selectedModel); + const hiddenModels = readInstanceModelPreferences(settings, entry.instanceId).hiddenModels; + if (unavailableSelection && !hiddenModels.includes(unavailableSelection)) { + return unavailableSelection; + } + } return ( - resolveSelectableModel(entry.driverKind, selectedModel, options) ?? options.find((option) => option.isDefault)?.slug ?? options[0]?.slug ?? entry.models.find((model) => model.isDefault)?.slug ?? @@ -268,12 +318,19 @@ export function resolveAppModelSelectionForInstance( export function getCustomModelOptionsByInstance( settings: UnifiedSettings, providers: ReadonlyArray, - _selectedInstanceId?: ProviderInstanceId | null, - _selectedModel?: string | null, + selectedInstanceId?: ProviderInstanceId | null, + selectedModel?: string | null, ): ReadonlyMap> { const out = new Map>(); for (const entry of deriveProviderInstanceEntries(providers)) { - out.set(entry.instanceId, getAppModelOptionsForInstance(settings, entry)); + out.set( + entry.instanceId, + getAppModelOptionsForInstance( + settings, + entry, + entry.instanceId === selectedInstanceId ? selectedModel : null, + ), + ); } return out; } diff --git a/apps/web/src/observability/clientTracing.ts b/apps/web/src/observability/clientTracing.ts index 2d07e218e85b..95d390b90026 100644 --- a/apps/web/src/observability/clientTracing.ts +++ b/apps/web/src/observability/clientTracing.ts @@ -131,17 +131,3 @@ async function disposeTracerRuntime( await settleAsyncResult(() => runtime.runPromiseExit(Scope.close(scope, Exit.void))); runtime.dispose(); } - -export async function __resetClientTracingForTests() { - configurationGeneration++; - activeConfigKey = null; - activeDelegate = null; - pendingConfiguration = Promise.resolve(); - - const runtime = activeRuntime; - const scope = activeScope; - activeRuntime = null; - activeScope = null; - - await disposeTracerRuntime(runtime, scope); -} diff --git a/apps/web/src/orchestrationEventEffects.ts b/apps/web/src/orchestrationEventEffects.ts deleted file mode 100644 index 34e33ace831c..000000000000 --- a/apps/web/src/orchestrationEventEffects.ts +++ /dev/null @@ -1,94 +0,0 @@ -import type { OrchestrationEvent, ThreadId } from "@t3tools/contracts"; - -export interface OrchestrationBatchEffects { - promoteDraftThreadIds: ThreadId[]; - clearDeletedThreadIds: ThreadId[]; - removeTerminalUiStateThreadIds: ThreadId[]; - needsProviderInvalidation: boolean; -} - -export function deriveOrchestrationBatchEffects( - events: readonly OrchestrationEvent[], -): OrchestrationBatchEffects { - const threadLifecycleEffects = new Map< - ThreadId, - { - clearPromotedDraft: boolean; - clearDeletedThread: boolean; - removeTerminalUiState: boolean; - } - >(); - let needsProviderInvalidation = false; - - for (const event of events) { - switch (event.type) { - case "thread.turn-diff-completed": - case "thread.reverted": { - needsProviderInvalidation = true; - break; - } - - case "thread.created": { - threadLifecycleEffects.set(event.payload.threadId, { - clearPromotedDraft: true, - clearDeletedThread: false, - removeTerminalUiState: false, - }); - break; - } - - case "thread.deleted": { - threadLifecycleEffects.set(event.payload.threadId, { - clearPromotedDraft: false, - clearDeletedThread: true, - removeTerminalUiState: true, - }); - break; - } - - case "thread.archived": { - threadLifecycleEffects.set(event.payload.threadId, { - clearPromotedDraft: false, - clearDeletedThread: false, - removeTerminalUiState: true, - }); - break; - } - - case "thread.unarchived": { - threadLifecycleEffects.set(event.payload.threadId, { - clearPromotedDraft: false, - clearDeletedThread: false, - removeTerminalUiState: false, - }); - break; - } - - default: { - break; - } - } - } - - const promoteDraftThreadIds: ThreadId[] = []; - const clearDeletedThreadIds: ThreadId[] = []; - const removeTerminalUiStateThreadIds: ThreadId[] = []; - for (const [threadId, effect] of threadLifecycleEffects) { - if (effect.clearPromotedDraft) { - promoteDraftThreadIds.push(threadId); - } - if (effect.clearDeletedThread) { - clearDeletedThreadIds.push(threadId); - } - if (effect.removeTerminalUiState) { - removeTerminalUiStateThreadIds.push(threadId); - } - } - - return { - promoteDraftThreadIds, - clearDeletedThreadIds, - removeTerminalUiStateThreadIds, - needsProviderInvalidation, - }; -} diff --git a/apps/web/src/orchestrationRecovery.ts b/apps/web/src/orchestrationRecovery.ts deleted file mode 100644 index c9ccf3a393d0..000000000000 --- a/apps/web/src/orchestrationRecovery.ts +++ /dev/null @@ -1,211 +0,0 @@ -export type OrchestrationRecoveryReason = - | "bootstrap" - | "sequence-gap" - | "resubscribe" - | "replay-failed"; - -export interface OrchestrationRecoveryPhase { - kind: "snapshot" | "replay"; - reason: OrchestrationRecoveryReason; -} - -export interface OrchestrationRecoveryState { - latestSequence: number; - highestObservedSequence: number; - bootstrapped: boolean; - pendingReplay: boolean; - inFlight: OrchestrationRecoveryPhase | null; -} - -export interface ReplayRecoveryCompletion { - replayMadeProgress: boolean; - shouldReplay: boolean; -} - -export interface ReplayRetryTracker { - attempts: number; - latestSequence: number; - highestObservedSequence: number; -} - -export interface ReplayRetryDecision { - shouldRetry: boolean; - delayMs: number; - tracker: ReplayRetryTracker | null; -} - -type SequencedEvent = Readonly<{ sequence: number }>; - -export function deriveReplayRetryDecision(input: { - previousTracker: ReplayRetryTracker | null; - completion: ReplayRecoveryCompletion; - recoveryState: Pick; - baseDelayMs: number; - maxNoProgressRetries: number; -}): ReplayRetryDecision { - if (!input.completion.shouldReplay) { - return { - shouldRetry: false, - delayMs: 0, - tracker: null, - }; - } - - if (input.completion.replayMadeProgress) { - return { - shouldRetry: true, - delayMs: 0, - tracker: null, - }; - } - - const previousTracker = input.previousTracker; - const sameFrontier = - previousTracker !== null && - previousTracker.latestSequence === input.recoveryState.latestSequence && - previousTracker.highestObservedSequence === input.recoveryState.highestObservedSequence; - - const attempts = sameFrontier && previousTracker !== null ? previousTracker.attempts + 1 : 1; - if (attempts > input.maxNoProgressRetries) { - return { - shouldRetry: false, - delayMs: 0, - tracker: null, - }; - } - - return { - shouldRetry: true, - delayMs: input.baseDelayMs * 2 ** (attempts - 1), - tracker: { - attempts, - latestSequence: input.recoveryState.latestSequence, - highestObservedSequence: input.recoveryState.highestObservedSequence, - }, - }; -} - -export function createOrchestrationRecoveryCoordinator() { - let state: OrchestrationRecoveryState = { - latestSequence: 0, - highestObservedSequence: 0, - bootstrapped: false, - pendingReplay: false, - inFlight: null, - }; - let replayStartSequence: number | null = null; - - const snapshotState = (): OrchestrationRecoveryState => ({ - ...state, - ...(state.inFlight ? { inFlight: { ...state.inFlight } } : {}), - }); - - const observeSequence = (sequence: number) => { - state.highestObservedSequence = Math.max(state.highestObservedSequence, sequence); - }; - - const resolveReplayNeedAfterRecovery = () => { - const pendingReplayBeforeReset = state.pendingReplay; - const observedAhead = state.highestObservedSequence > state.latestSequence; - const shouldReplay = pendingReplayBeforeReset || observedAhead; - state.pendingReplay = false; - return { - shouldReplay, - pendingReplayBeforeReset, - observedAhead, - }; - }; - - return { - getState(): OrchestrationRecoveryState { - return snapshotState(); - }, - - classifyDomainEvent(sequence: number): "ignore" | "defer" | "recover" | "apply" { - observeSequence(sequence); - if (sequence <= state.latestSequence) { - return "ignore"; - } - if (!state.bootstrapped || state.inFlight) { - state.pendingReplay = true; - return "defer"; - } - if (sequence !== state.latestSequence + 1) { - state.pendingReplay = true; - return "recover"; - } - return "apply"; - }, - - markEventBatchApplied(events: ReadonlyArray): ReadonlyArray { - const nextEvents = events - .filter((event) => event.sequence > state.latestSequence) - .toSorted((left, right) => left.sequence - right.sequence); - if (nextEvents.length === 0) { - return []; - } - - state.latestSequence = nextEvents.at(-1)?.sequence ?? state.latestSequence; - state.highestObservedSequence = Math.max(state.highestObservedSequence, state.latestSequence); - return nextEvents; - }, - - beginSnapshotRecovery(reason: OrchestrationRecoveryReason): boolean { - if (state.inFlight?.kind === "snapshot") { - state.pendingReplay = true; - return false; - } - if (state.inFlight?.kind === "replay") { - state.pendingReplay = true; - return false; - } - state.inFlight = { kind: "snapshot", reason }; - return true; - }, - - completeSnapshotRecovery(snapshotSequence: number): boolean { - state.latestSequence = Math.max(state.latestSequence, snapshotSequence); - state.highestObservedSequence = Math.max(state.highestObservedSequence, state.latestSequence); - state.bootstrapped = true; - state.inFlight = null; - return resolveReplayNeedAfterRecovery().shouldReplay; - }, - - failSnapshotRecovery(): void { - state.inFlight = null; - }, - - beginReplayRecovery(reason: OrchestrationRecoveryReason): boolean { - if (!state.bootstrapped || state.inFlight?.kind === "snapshot") { - state.pendingReplay = true; - return false; - } - if (state.inFlight?.kind === "replay") { - state.pendingReplay = true; - return false; - } - state.pendingReplay = false; - replayStartSequence = state.latestSequence; - state.inFlight = { kind: "replay", reason }; - return true; - }, - - completeReplayRecovery(): ReplayRecoveryCompletion { - const replayMadeProgress = - replayStartSequence !== null && state.latestSequence > replayStartSequence; - replayStartSequence = null; - state.inFlight = null; - const replayResolution = resolveReplayNeedAfterRecovery(); - return { - replayMadeProgress, - shouldReplay: replayResolution.shouldReplay, - }; - }, - - failReplayRecovery(): void { - replayStartSequence = null; - state.bootstrapped = false; - state.inFlight = null; - }, - }; -} diff --git a/apps/web/src/pendingUserInput.test.ts b/apps/web/src/pendingUserInput.test.ts index 3d1cb336e1f3..2d4eaeace220 100644 --- a/apps/web/src/pendingUserInput.test.ts +++ b/apps/web/src/pendingUserInput.test.ts @@ -4,7 +4,6 @@ import { buildPendingUserInputAnswers, countAnsweredPendingUserInputQuestions, derivePendingUserInputProgress, - findFirstUnansweredPendingUserInputQuestionIndex, resolvePendingUserInputAnswer, setPendingUserInputCustomAnswer, togglePendingUserInputOptionSelection, @@ -182,29 +181,6 @@ describe("pending user input question progress", () => { ).toBe(1); }); - it("finds the first unanswered question", () => { - expect( - findFirstUnansweredPendingUserInputQuestionIndex(questions, { - scope: { - selectedOptionLabels: ["Orchestration-first"], - }, - }), - ).toBe(1); - }); - - it("returns the last question index when all answers are complete", () => { - expect( - findFirstUnansweredPendingUserInputQuestionIndex(questions, { - scope: { - selectedOptionLabels: ["Orchestration-first"], - }, - compat: { - customAnswer: "Keep it for one release window", - }, - }), - ).toBe(1); - }); - it("derives the active question and advancement state", () => { expect( derivePendingUserInputProgress( diff --git a/apps/web/src/pendingUserInput.ts b/apps/web/src/pendingUserInput.ts index d3a7a129378b..76868ad14a16 100644 --- a/apps/web/src/pendingUserInput.ts +++ b/apps/web/src/pendingUserInput.ts @@ -128,17 +128,6 @@ export function countAnsweredPendingUserInputQuestions( }, 0); } -export function findFirstUnansweredPendingUserInputQuestionIndex( - questions: ReadonlyArray, - draftAnswers: Record, -): number { - const unansweredIndex = questions.findIndex( - (question) => !resolvePendingUserInputAnswer(question, draftAnswers[question.id]), - ); - - return unansweredIndex === -1 ? Math.max(questions.length - 1, 0) : unansweredIndex; -} - export function derivePendingUserInputProgress( questions: ReadonlyArray, draftAnswers: Record, diff --git a/apps/web/src/pierre-icons.ts b/apps/web/src/pierre-icons.ts index b2511563df05..b4b83e4df976 100644 --- a/apps/web/src/pierre-icons.ts +++ b/apps/web/src/pierre-icons.ts @@ -3,6 +3,7 @@ import { getBuiltInSpriteSheet, type FileTreeIcons, } from "@pierre/trees"; +import { VIDEO_FILE_EXTENSIONS } from "@t3tools/shared/video"; export interface PierreIconResolution { name: string; @@ -13,6 +14,13 @@ const PIERRE_ICON_SPRITE_ID = "t3code-pierre-file-icon-sprite"; const T3_FILE_ICON_SPRITE = `
    - {groups.map((group) => ( + {displayGroups.map((group) => (
    {group.label ? (

    {group.label}

    ) : null} - {group.entries.map((entry) => ( - 1 && - environmentLabels.get(entry.environmentId) !== undefined - ? { environmentLabel: environmentLabels.get(entry.environmentId)! } - : {})} - // Ten is the floor the ranking gives a row whose own fields say nothing - // about the search: the host matched something this row cannot show. - matchedElsewhere={ - typedParsed.text.length > 0 && - scorePullRequestMatch(entry, typedParsed.text) <= MATCHED_ELSEWHERE_SCORE - } - selected={ - selected?.environmentId === entry.environmentId && - selected.repository === entry.repository && - selected.number === entry.number - } - onSelect={selectEntry} - /> - ))} + {group.entries.map((entry) => { + const entryKey = pullRequestEntryKey(entry); + return ( + 1 && + environmentLabels.get(entry.environmentId) !== undefined + ? { environmentLabel: environmentLabels.get(entry.environmentId)! } + : {})} + // Ten is the floor the ranking gives a row whose own fields say nothing + // about the search: the host matched something this row cannot show. + matchedElsewhere={ + typedParsed.text.length > 0 && + scorePullRequestMatch(entry, typedParsed.text) <= MATCHED_ELSEWHERE_SCORE + } + selected={ + selected?.environmentId === entry.environmentId && + selected.repository === entry.repository && + selected.number === entry.number + } + onSelect={selectEntry} + /> + ); + })}
    ))}
    @@ -1449,8 +1702,20 @@ function PullRequestsRouteView() { Icon: environment.displayUrl === null ? MonitorIcon : ServerIcon, })), ]; + const sortMenu = ( + } + triggerLabel="Sort" + outlined + value={sort} + options={SORT_OPTIONS} + onChange={(next) => updateSearch({ sort: next })} + /> + ); const filtersMenu = ( updateListScope({ state })} @@ -1459,8 +1724,16 @@ function PullRequestsRouteView() { onInvolvement={(involvement) => updateListScope({ involvement })} filters={menuFilters} onFilters={(next) => - updateListScope({ draft: next.draft, review: next.review, checks: next.checks }) + updateListScope({ + draft: next.draft, + review: next.review, + checks: next.checks, + author: next.author, + labels: next.labels?.flatMap((group) => group), + }) } + authorOptions={facets.authors} + labelOptions={facets.labels} host={search.host} hostOptions={hostMenuOptions} onHost={(host) => updateListScope({ host })} @@ -1493,6 +1766,7 @@ function PullRequestsRouteView() { onState: (state: PullRequestListState) => updateListScope({ state }), onHost: (host: string | undefined) => updateListScope({ host }), searchInput, + sortMenu, filtersMenu, rightPanelControl: // Footprint reserve while the panel is closed: the toggle itself stays @@ -1511,6 +1785,7 @@ function PullRequestsRouteView() { pullRequestsSupported && !rightPanelState.isOpen ? openPanelControls : null, rightPanelOpen: rightPanelState.isOpen, listBody, + scrollRef, }; const activateSurface = (surface: PullRequestSurface) => { @@ -1625,25 +1900,50 @@ function PullRequestsRouteView() { /** A compact stand-in for one pill group when the header is narrow. */ function CompactFilterMenu({ label, + triggerIcon, + triggerLabel, + outlined = false, value, options, onChange, + className, }: { label: string; + triggerIcon?: ReactNode; + triggerLabel?: string; + outlined?: boolean; value: Value; options: ReadonlyArray>; onChange: (value: Value) => void; + className?: string; }) { const current = options.find((option) => option.value === value) ?? options[0]; if (!current) return null; return (
    : undefined} + className={ + outlined + ? className + : cn( + "inline-flex h-7 min-w-0 items-center gap-1 rounded-md px-1.5 text-sm font-medium text-muted-foreground hover:bg-accent hover:text-foreground", + className, + ) + } > - {current.label} - + {triggerLabel ? ( + <> + {triggerIcon} + {triggerLabel} + + ) : ( + <> + {current.label} + + + )} onChange(next as Value)}> @@ -1656,7 +1956,7 @@ function CompactFilterMenu({ className="data-disabled:pointer-events-auto" > - + {option.label} @@ -1721,7 +2021,7 @@ function ExpandableSearch({ return (
    onFocusWithin?.(true)} onBlur={() => { onFocusWithin?.(false); @@ -1764,11 +2064,13 @@ function PullRequestsColumn({ onState, onHost, searchInput, + sortMenu, filtersMenu, rightPanelControl, titlebarControls, rightPanelOpen, listBody, + scrollRef, }: { refreshing: boolean; onRefresh: () => void; @@ -1781,13 +2083,14 @@ function PullRequestsColumn({ onState: (state: PullRequestListState) => void; onHost: (host: string | undefined) => void; searchInput: ReactNode; + sortMenu: ReactNode; filtersMenu: ReactNode; rightPanelControl: ReactNode; titlebarControls: ReactNode; rightPanelOpen: boolean; listBody: ReactNode; + scrollRef: RefObject; }) { - const scrollRef = useRef(null); const markerRef = useRef(null); const [condensed, setCondensed] = useState(false); useEffect(() => { @@ -1808,6 +2111,7 @@ function PullRequestsColumn({ const inFlowSearchRef = useRef(null); const [searchOpen, setSearchOpen] = useState(false); const [searchFocusToken, setSearchFocusToken] = useState(0); + const searchExpanded = searchOpen || searchValue.length > 0; // Mod+F belongs to this page's own search: the desktop shell binds no find-in-page, so the // shortcut would otherwise do nothing. Condensed, it unfolds the topbar search; at the top, // it focuses the in-flow bar and selects the query the way a find field would. @@ -1860,20 +2164,20 @@ function PullRequestsColumn({ > {titlebarControls} {condensed ? ( - - {/* The page name remains the foreground anchor in both states; the live filters are - its compact scope, grouped as the second crumb rather than pretending each menu - is a separate page in the hierarchy. */} - + + {/* An expanded search owns the scarce horizontal space. The page title stays + available to readers while the live filters remain available in both states. */} +

    Pull Requests

    - - + {searchExpanded ? null : } + {condensed ? ( -
    +
    - {/* The top padding is the fade band's own height (1.5rem here), the same pairing the + {/* The top padding is the shared fade band's height, the same pairing the settings page makes: at rest the controls sit fully below the mask, and only content actually passing under the chrome fades. */}
    {searchInput} + {sortMenu} {filtersMenu} {!condensed ? ( diff --git a/apps/web/src/rpc/atomRegistry.ts b/apps/web/src/rpc/atomRegistry.ts index 3fb12914a2fb..c9ac7ef82bac 100644 --- a/apps/web/src/rpc/atomRegistry.ts +++ b/apps/web/src/rpc/atomRegistry.ts @@ -2,13 +2,8 @@ import { RegistryContext } from "@effect/atom-react"; import { AtomRegistry } from "effect/unstable/reactivity"; import { createElement } from "react"; -export let appAtomRegistry = AtomRegistry.make(); +export const appAtomRegistry = AtomRegistry.make(); export function AppAtomRegistryProvider({ children }: React.PropsWithChildren) { return createElement(RegistryContext.Provider, { value: appAtomRegistry }, children); } - -export function resetAppAtomRegistryForTests() { - appAtomRegistry.dispose(); - appAtomRegistry = AtomRegistry.make(); -} diff --git a/apps/web/src/rpc/requestLatencyState.ts b/apps/web/src/rpc/requestLatencyState.ts index 4ec5b56f9e2b..cb68a090775a 100644 --- a/apps/web/src/rpc/requestLatencyState.ts +++ b/apps/web/src/rpc/requestLatencyState.ts @@ -153,10 +153,6 @@ export function resetRequestLatencyStateForTests(): void { clearAllTrackedRpcRequests(); } -export function setSlowRpcAckThresholdMsForTests(thresholdMs: number): void { - slowRpcAckThresholdMs = thresholdMs; -} - export function useSlowRpcAckRequests(): ReadonlyArray { return useAtomValue(slowRpcAckRequestsAtom); } diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index 0c07e53db677..dc32f84b1e5d 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -11,7 +11,6 @@ import { describe, expect, it } from "vite-plus/test"; import { deriveActiveWorkStartedAt, deriveActivePlanState, - deriveTurnPlans, derivePendingApprovals, derivePendingUserInputs, deriveTimelineEntries, @@ -495,73 +494,6 @@ describe("deriveActivePlanState", () => { { durationMs: 3_000, step: "Check", status: "completed" }, ]); }); -}); - -describe("deriveTurnPlans", () => { - it("keeps one entry per turn, anchored at the first snapshot with the latest steps", () => { - const activities: OrchestrationThreadActivity[] = [ - makeActivity({ - id: "plan-1a", - createdAt: "2026-02-23T00:00:01.000Z", - kind: "turn.plan.updated", - summary: "Plan updated", - tone: "info", - turnId: "turn-1", - payload: { - plan: [{ step: "Inspect code", status: "inProgress" }], - }, - }), - makeActivity({ - id: "plan-1b", - createdAt: "2026-02-23T00:00:05.000Z", - kind: "turn.plan.updated", - summary: "Plan updated", - tone: "info", - turnId: "turn-1", - payload: { - plan: [{ step: "Inspect code", status: "completed" }], - }, - }), - makeActivity({ - id: "plan-2a", - createdAt: "2026-02-23T00:01:00.000Z", - kind: "turn.plan.updated", - summary: "Plan updated", - tone: "info", - turnId: "turn-2", - payload: { - plan: [{ step: "Ship it", status: "pending" }], - }, - }), - ]; - - const turnPlans = deriveTurnPlans(activities); - expect(turnPlans).toHaveLength(2); - expect(turnPlans[0]).toMatchObject({ - id: "turn-plan:turn-1", - createdAt: "2026-02-23T00:00:01.000Z", - turnId: "turn-1", - }); - expect(turnPlans[0]?.plan.steps).toEqual([ - { durationMs: 4_000, step: "Inspect code", status: "completed" }, - ]); - expect(turnPlans[1]?.plan.steps).toEqual([{ step: "Ship it", status: "pending" }]); - }); - - it("skips activities without parseable steps", () => { - const activities: OrchestrationThreadActivity[] = [ - makeActivity({ - id: "plan-bad", - createdAt: "2026-02-23T00:00:01.000Z", - kind: "turn.plan.updated", - summary: "Plan updated", - tone: "info", - turnId: "turn-1", - payload: { plan: [] }, - }), - ]; - expect(deriveTurnPlans(activities)).toEqual([]); - }); it("tracks repeated step labels independently", () => { const activities: OrchestrationThreadActivity[] = [ @@ -609,7 +541,7 @@ describe("deriveTurnPlans", () => { }), ]; - expect(deriveTurnPlans(activities)[0]?.plan.steps).toEqual([ + expect(deriveActivePlanState(activities, TurnId.make("turn-1"))?.steps).toEqual([ { durationMs: 4_000, step: "Check", status: "completed" }, { durationMs: 6_000, step: "Check", status: "completed" }, ]); @@ -661,13 +593,13 @@ describe("deriveTurnPlans", () => { }), ]; - expect(deriveTurnPlans(activities)[0]?.plan.steps).toEqual([ + expect(deriveActivePlanState(activities, TurnId.make("turn-1"))?.steps).toEqual([ { durationMs: 5_000, step: "First", status: "completed" }, { durationMs: 5_000, step: "Second", status: "completed" }, ]); }); - it("drops a turn's chip when a later snapshot clears the plan", () => { + it("clears the active plan when a later snapshot has no steps", () => { const activities: OrchestrationThreadActivity[] = [ makeActivity({ id: "plan-set", @@ -688,7 +620,7 @@ describe("deriveTurnPlans", () => { payload: { plan: [] }, }), ]; - expect(deriveTurnPlans(activities)).toEqual([]); + expect(deriveActivePlanState(activities, TurnId.make("turn-1"))).toBeNull(); }); }); @@ -915,6 +847,33 @@ describe("workEntryIndicatesToolFailure", () => { }); describe("deriveWorkLogEntries", () => { + it("keeps the latest task progress without emitting plan-update log entries", () => { + const activities = [ + makeActivity({ id: "before", kind: "tool.completed", summary: "Read files", sequence: 0 }), + makeActivity({ + id: "plan-1", + kind: "turn.plan.updated", + summary: "Plan updated", + turnId: "turn-1", + sequence: 1, + payload: { plan: [{ step: "Verify the composer", status: "inProgress" }] }, + }), + makeActivity({ + id: "plan-2", + kind: "turn.plan.updated", + summary: "Plan updated", + turnId: "turn-1", + sequence: 2, + payload: { plan: [{ step: "Verify the composer", status: "completed" }] }, + }), + makeActivity({ id: "after", kind: "tool.completed", summary: "Ran tests", sequence: 3 }), + ]; + expect(deriveWorkLogEntries(activities).map((entry) => entry.id)).toEqual(["before", "after"]); + expect(deriveActivePlanState(activities, TurnId.make("turn-1"))?.steps).toMatchObject([ + { step: "Verify the composer", status: "completed" }, + ]); + }); + it("omits tool started entries and keeps completed entries", () => { const activities: OrchestrationThreadActivity[] = [ makeActivity({ @@ -935,6 +894,111 @@ describe("deriveWorkLogEntries", () => { expect(entries.map((entry) => entry.id)).toEqual(["tool-complete"]); }); + it("omits routine setup updates before work starts and after later turn activity", () => { + const setupActivities = [ + makeActivity({ + id: "setup-requested", + kind: "setup-script.requested", + summary: "Preparing setup script", + tone: "info", + sequence: 1, + }), + makeActivity({ + id: "setup-started", + kind: "setup-script.started", + summary: "Setup script started", + tone: "info", + sequence: 2, + }), + ]; + + expect(deriveWorkLogEntries(setupActivities)).toEqual([]); + expect( + deriveWorkLogEntries([ + ...setupActivities, + makeActivity({ + id: "first-turn-tool", + kind: "tool.completed", + summary: "Read project files", + turnId: "turn-1", + sequence: 3, + }), + makeActivity({ + id: "later-turn-tool", + kind: "tool.completed", + summary: "Ran tests", + turnId: "turn-2", + sequence: 4, + }), + ]).map((entry) => entry.id), + ).toEqual(["first-turn-tool", "later-turn-tool"]); + }); + + it("preserves setup failures and unrelated info without a turn id", () => { + const entries = deriveWorkLogEntries([ + makeActivity({ + id: "setup-requested", + kind: "setup-script.requested", + summary: "Preparing setup script", + tone: "info", + sequence: 1, + }), + makeActivity({ + id: "setup-failed", + kind: "setup-script.failed", + summary: "Setup script failed to start", + tone: "error", + payload: { detail: "Could not start the setup terminal" }, + sequence: 2, + }), + makeActivity({ + id: "runtime-notice", + kind: "runtime.warning", + summary: "Reconnecting to provider", + tone: "info", + sequence: 3, + }), + ]); + + expect(entries).toMatchObject([ + { + id: "setup-failed", + label: "Setup script failed to start", + tone: "error", + detail: "Could not start the setup terminal", + turnId: null, + }, + { + id: "runtime-notice", + label: "Reconnecting to provider", + tone: "info", + turnId: null, + }, + ]); + }); + + it("drops runtime warnings with no displayable content, keeps ones with a preview", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "warning-noise", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "runtime.warning", + summary: "Claude system message 'background_tasks_changed' (no displayable text content)", + tone: "info", + }), + makeActivity({ + id: "warning-signal", + createdAt: "2026-02-23T00:00:02.000Z", + kind: "runtime.warning", + summary: "Reconnecting... 2/5", + tone: "info", + }), + ]; + + const entries = deriveWorkLogEntries(activities); + expect(entries.map((entry) => entry.id)).toEqual(["warning-signal"]); + }); + it("omits task.started but shows task.progress and task.completed", () => { const activities: OrchestrationThreadActivity[] = [ makeActivity({ @@ -2259,7 +2323,7 @@ describe("session activity performance", () => { expect(appendedEntries[1]).toBe(initialEntries[1]); }); - it("updates 20,000 ordered tool activities within 100 ms", () => { + it("reuses entries when appending to 20,000 ordered tool activities", () => { const activities = Array.from({ length: 20_000 }, (_, index) => makeActivity({ id: `benchmark-tool-${index}`, @@ -2277,7 +2341,8 @@ describe("session activity performance", () => { }, }), ); - deriveWorkLogEntries(activities); + const initialEntries = deriveWorkLogEntries(activities); + expect(initialEntries).toHaveLength(20_000); const updatedActivities = [ ...activities, makeActivity({ @@ -2294,8 +2359,13 @@ describe("session activity performance", () => { }), ]; - const startedAt = performance.now(); - expect(deriveWorkLogEntries(updatedActivities)).toHaveLength(20_001); - expect(performance.now() - startedAt).toBeLessThan(100); + const updatedEntries = deriveWorkLogEntries(updatedActivities); + expect(updatedEntries).toHaveLength(20_001); + expect(initialEntries.every((entry, index) => updatedEntries[index] === entry)).toBe(true); + expect(updatedEntries.at(-1)).toMatchObject({ + id: "benchmark-tool-appended", + command: "git diff", + toolLifecycleStatus: "completed", + }); }); }); diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 59165ec6b726..6d853dc3ea1e 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -2,6 +2,7 @@ import * as Option from "effect/Option"; import * as Arr from "effect/Array"; import * as Schema from "effect/Schema"; import { isBackgroundTaskActivity } from "@t3tools/client-runtime/state/subagentRuntime"; +import { isWorktreeSetupActivity } from "@t3tools/client-runtime/work-log/presentation"; import { ApprovalRequestId, isToolLifecycleItemType, @@ -169,12 +170,6 @@ export type TimelineEntry = createdAt: string; proposedPlan: ProposedPlan; } - | { - id: string; - kind: "turn-plan"; - createdAt: string; - turnPlan: TurnPlanEntry; - } | { id: string; kind: "work"; @@ -279,6 +274,17 @@ export function workEntryDisplayIndicatesToolFailure(entry: WorkLogEntry): boole return workEntryIndicatesToolFailureFromOutput(entry, false); } +/** Severe failures keep the red treatment ordinary tool failures lost: runtime + * errors and orchestration `*.failed` activities (provider.turn.start.failed, + * checkpoint.capture.failed, ...) mean the turn or a core side effect broke, + * not that a command exited nonzero. */ +export function workEntrySignalsSevereFailure(entry: WorkLogEntry): boolean { + return ( + entry.sourceActivityKind === "runtime.error" || + entry.sourceActivityKind?.endsWith(".failed") === true + ); +} + /** Tool/command row completed without failure (blue check affordance). */ export function workEntryIndicatesToolSuccess(entry: WorkLogEntry): boolean { if (!workLogEntryIsToolLike(entry)) { @@ -701,62 +707,6 @@ export function deriveActivePlanState( return addPlanStepDurations(plan, matchingActivities.slice(latestClearIndex + 1)); } -export interface TurnPlanEntry { - /** Stable per-turn row id (plans rewrite constantly; the row must not churn). */ - id: string; - /** Anchor timestamp: the turn's FIRST plan activity, so the chip renders where planning began. */ - createdAt: string; - turnId: TurnId | null; - plan: ActivePlanState; -} - -/** - * One inline plan chip per turn that produced plan/todo steps: the latest - * snapshot for the turn, anchored at the first snapshot's timestamp. Turn-less - * plan activities collapse into a single chip keyed by thread order. - */ -export function deriveTurnPlans( - activities: ReadonlyArray, -): TurnPlanEntry[] { - const ordered = [...activities].toSorted(compareActivitiesByOrder); - const byTurn = new Map< - string, - { activities: OrchestrationThreadActivity[]; entry: TurnPlanEntry } - >(); - for (const activity of ordered) { - if (activity.kind !== "turn.plan.updated") { - continue; - } - const plan = planStateFromActivity(activity); - const key = activity.turnId ?? "no-turn"; - if (!plan) { - // A later snapshot with no steps clears the turn's plan; keeping the - // stale entry would freeze the chip on a withdrawn plan. - byTurn.delete(key); - continue; - } - const existing = byTurn.get(key); - if (existing) { - existing.entry.plan = plan; - existing.activities.push(activity); - } else { - byTurn.set(key, { - activities: [activity], - entry: { - id: `turn-plan:${key}`, - createdAt: activity.createdAt, - turnId: activity.turnId, - plan, - }, - }); - } - } - return [...byTurn.values()].map(({ activities: planActivities, entry }) => ({ - ...entry, - plan: addPlanStepDurations(entry.plan, planActivities), - })); -} - export function findLatestProposedPlan( proposedPlans: ReadonlyArray, latestTurnId: TurnId | string | null | undefined, @@ -860,6 +810,7 @@ export function deriveWorkLogEntries( const ordered = [...activities].toSorted(compareActivitiesByOrder); const entries: DerivedWorkLogEntry[] = []; for (const activity of ordered) { + if (activity.tone !== "error" && isWorktreeSetupActivity(activity.kind)) continue; if (activity.kind === "tool.started") continue; // Agent task.started rows are CTA seeds: they carry the true spawn turn, // which is the batch key (completions of background subagents arrive @@ -869,7 +820,9 @@ export function deriveWorkLogEntries( if (activity.kind === "task.updated") continue; if (activity.kind === "tool.progress") continue; if (activity.kind === "context-window.updated") continue; + if (activity.kind === "turn.plan.updated") continue; if (activity.summary === "Checkpoint captured") continue; + if (isNoContentRuntimeWarning(activity)) continue; if (isPlanBoundaryToolActivity(activity)) continue; if (isAgentInternalActivity(activity)) continue; entries.push(toDerivedWorkLogEntry(activity)); @@ -877,6 +830,17 @@ export function deriveWorkLogEntries( return collapseDerivedWorkLogEntries(entries); } +/** Adapters forward unknown wire-only SDK messages (background_tasks_changed, + * commands_changed, ...) as runtime warnings. The suffix comes from + * describeUnknownSdkMessage in the Claude adapter; a row with no displayable + * text carries nothing a user can act on, so it does not render. */ +function isNoContentRuntimeWarning(activity: OrchestrationThreadActivity): boolean { + return ( + activity.kind === "runtime.warning" && + activity.summary.endsWith("(no displayable text content)") + ); +} + function isPlanBoundaryToolActivity(activity: OrchestrationThreadActivity): boolean { if (activity.kind !== "tool.updated" && activity.kind !== "tool.completed") { return false; @@ -1824,7 +1788,6 @@ export function deriveTimelineEntries( messages: ReadonlyArray, proposedPlans: ReadonlyArray, workEntries: ReadonlyArray, - turnPlans: ReadonlyArray = [], ): TimelineEntry[] { const messageRows: TimelineEntry[] = messages.map((message) => ({ id: message.id, @@ -1838,19 +1801,13 @@ export function deriveTimelineEntries( createdAt: proposedPlan.createdAt, proposedPlan, })); - const turnPlanRows: TimelineEntry[] = turnPlans.map((turnPlan) => ({ - id: turnPlan.id, - kind: "turn-plan", - createdAt: turnPlan.createdAt, - turnPlan, - })); const workRows: TimelineEntry[] = workEntries.map((entry) => ({ id: entry.id, kind: "work", createdAt: entry.createdAt, entry, })); - return [...messageRows, ...proposedPlanRows, ...turnPlanRows, ...workRows].toSorted((a, b) => + return [...messageRows, ...proposedPlanRows, ...workRows].toSorted((a, b) => a.createdAt.localeCompare(b.createdAt), ); } diff --git a/apps/web/src/shortcutModifierState.test.ts b/apps/web/src/shortcutModifierState.test.ts index cb62d45bcc0b..4ef4b3b6bf2d 100644 --- a/apps/web/src/shortcutModifierState.test.ts +++ b/apps/web/src/shortcutModifierState.test.ts @@ -110,4 +110,29 @@ describe("shortcutModifierState", () => { shiftKey: false, }); }); + + it("ignores poisoned modifier flags on non-modifier keys", () => { + // A dictation paste (synthetic ⌘V) can leave the browser reporting + // metaKey=true on later real key events. Enter to submit must not + // re-mark ⌘ as held. + const state = shortcutModifierStateAfterKeyboardEvent( + emptyState(), + keyboardEventLike("keydown", { key: "Enter", metaKey: true }), + ); + expect(state).toEqual(emptyState()); + }); + + it("clears a held modifier when a non-modifier key reports it released", () => { + const heldMeta: ShortcutModifierState = { + metaKey: true, + ctrlKey: false, + altKey: false, + shiftKey: false, + }; + const state = shortcutModifierStateAfterKeyboardEvent( + heldMeta, + keyboardEventLike("keydown", { key: "a", metaKey: false }), + ); + expect(state).toEqual(emptyState()); + }); }); diff --git a/apps/web/src/shortcutModifierState.ts b/apps/web/src/shortcutModifierState.ts index a56a1a129d07..3abeeaa3e8aa 100644 --- a/apps/web/src/shortcutModifierState.ts +++ b/apps/web/src/shortcutModifierState.ts @@ -33,7 +33,12 @@ export function useShortcutModifierState(): ShortcutModifierState { const onKeyboardEvent = (event: KeyboardEvent) => { setState((current) => shortcutModifierStateAfterKeyboardEvent(current, event)); }; - const onWindowBlur = () => { + // Dictation tools (Wispr Flow) paste with a synthetic ⌘V whose Meta keyup + // never reaches the page, so the tracked state stays "⌘ held" forever and + // the thread jump hints stick on screen. A paste is never jump intent, so + // treat it like a blur and reset. A physically held modifier re-registers + // on the next real key event. + const onResetEvent = () => { setState((current) => areShortcutModifierStatesEqual(current, EMPTY_SHORTCUT_MODIFIER_STATE) ? current @@ -43,11 +48,13 @@ export function useShortcutModifierState(): ShortcutModifierState { window.addEventListener("keydown", onKeyboardEvent, true); window.addEventListener("keyup", onKeyboardEvent, true); - window.addEventListener("blur", onWindowBlur); + window.addEventListener("paste", onResetEvent, true); + window.addEventListener("blur", onResetEvent); return () => { window.removeEventListener("keydown", onKeyboardEvent, true); window.removeEventListener("keyup", onKeyboardEvent, true); - window.removeEventListener("blur", onWindowBlur); + window.removeEventListener("paste", onResetEvent, true); + window.removeEventListener("blur", onResetEvent); }; }, []); @@ -84,11 +91,17 @@ export function shortcutModifierStateAfterKeyboardEvent( [normalizedModifierKey]: event.type === "keydown", }; } else { + // Flags on non-modifier keys may only clear a bit, never set one. After a + // dictation tool's synthetic ⌘V (Wispr Flow), the browser can keep + // reporting metaKey=true on real key events (Enter to submit) until the + // user physically taps ⌘. Trusting that flag would mark ⌘ as held and + // stick the thread jump hints. Setting a bit requires a real modifier + // keydown, handled above. nextState = { - metaKey: event.metaKey, - ctrlKey: event.ctrlKey, - altKey: event.altKey, - shiftKey: event.shiftKey, + metaKey: currentState.metaKey && event.metaKey, + ctrlKey: currentState.ctrlKey && event.ctrlKey, + altKey: currentState.altKey && event.altKey, + shiftKey: currentState.shiftKey && event.shiftKey, }; } diff --git a/apps/web/src/sidebarProjectGrouping.ts b/apps/web/src/sidebarProjectGrouping.ts index be92fcaee849..8cf3c5665aca 100644 --- a/apps/web/src/sidebarProjectGrouping.ts +++ b/apps/web/src/sidebarProjectGrouping.ts @@ -118,22 +118,23 @@ export function buildSidebarProjectPickerEntries(input: { groups: ReadonlyArray; preferredProjectRef: ScopedProjectRef | null; }) { + const preferredProjectRef = input.preferredProjectRef; const entries = input.groups.flatMap((group): SidebarProjectPickerEntry[] => { - const isPreferred = input.preferredProjectRef + const isPreferred = preferredProjectRef ? group.memberProjectRefs.some( (projectRef) => - projectRef.environmentId === input.preferredProjectRef?.environmentId && - projectRef.projectId === input.preferredProjectRef.projectId, + projectRef.environmentId === preferredProjectRef.environmentId && + projectRef.projectId === preferredProjectRef.projectId, ) : false; - const preferredProject = isPreferred + const preferredProject = preferredProjectRef ? (group.memberProjects.find( (project) => - project.environmentId === input.preferredProjectRef?.environmentId && - project.id === input.preferredProjectRef?.projectId, + project.environmentId === preferredProjectRef.environmentId && + project.id === preferredProjectRef.projectId, ) ?? group.memberProjects.find( - (project) => project.environmentId === input.preferredProjectRef?.environmentId, + (project) => project.environmentId === preferredProjectRef.environmentId, )) : null; const targetProject = diff --git a/apps/web/src/state/attachments.ts b/apps/web/src/state/attachments.ts index 8b600d6c004a..3377a96c1ecf 100644 --- a/apps/web/src/state/attachments.ts +++ b/apps/web/src/state/attachments.ts @@ -1,15 +1,5 @@ -import { WS_METHODS } from "@t3tools/contracts"; -import { createEnvironmentRpcCommand } from "@t3tools/client-runtime/state/runtime"; +import { createAttachmentEnvironmentAtoms } from "@t3tools/client-runtime/state/attachments"; import { connectionAtomRuntime } from "../connection/runtime"; -export const attachmentEnvironment = { - createUploadUrl: createEnvironmentRpcCommand(connectionAtomRuntime, { - label: "environment-command:attachments:create-upload-url", - tag: WS_METHODS.attachmentsCreateUploadUrl, - }), - remove: createEnvironmentRpcCommand(connectionAtomRuntime, { - label: "environment-command:attachments:delete", - tag: WS_METHODS.attachmentsDelete, - }), -}; +export const attachmentEnvironment = createAttachmentEnvironmentAtoms(connectionAtomRuntime); diff --git a/apps/web/src/state/entities.ts b/apps/web/src/state/entities.ts index 7bca31182379..ec1e4c836211 100644 --- a/apps/web/src/state/entities.ts +++ b/apps/web/src/state/entities.ts @@ -8,16 +8,8 @@ import { type EnvironmentThreadStatus, mergeEnvironmentThread, } from "@t3tools/client-runtime/state/threads"; -import type { - OrchestrationMessage, - OrchestrationProposedPlan, - OrchestrationSession, - OrchestrationThreadActivity, - ScopedProjectRef, - ScopedThreadRef, - ServerConfig, -} from "@t3tools/contracts"; -import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import type { ScopedProjectRef, ScopedThreadRef, ServerConfig } from "@t3tools/contracts"; +import type { EnvironmentId } from "@t3tools/contracts"; import { Atom } from "effect/unstable/reactivity"; import { useMemo } from "react"; import { appAtomRegistry } from "../rpc/atomRegistry"; @@ -26,18 +18,11 @@ import { environmentServerConfigsAtom } from "./server"; import { allEnvironmentShellsBootstrappedAtom } from "./shell"; import { environmentThreadDetails, environmentThreadShells } from "./threads"; -const EMPTY_PROJECT_REFS: ReadonlyArray = Object.freeze([]); const EMPTY_THREAD_REFS: ReadonlyArray = Object.freeze([]); -const EMPTY_MESSAGES: ReadonlyArray = Object.freeze([]); -const EMPTY_ACTIVITIES: ReadonlyArray = Object.freeze([]); -const EMPTY_PROPOSED_PLANS: ReadonlyArray = Object.freeze([]); const EMPTY_PROJECT_ATOM = Atom.make(null).pipe( Atom.withLabel("web-project:empty"), ); -const EMPTY_PROJECT_REFS_ATOM = Atom.make(EMPTY_PROJECT_REFS).pipe( - Atom.withLabel("web-project-refs:empty"), -); const EMPTY_THREAD_REFS_ATOM = Atom.make(EMPTY_THREAD_REFS).pipe( Atom.withLabel("web-thread-refs:empty"), ); @@ -50,18 +35,6 @@ const EMPTY_THREAD_DETAIL_ATOM = Atom.make(null).pipe( const EMPTY_THREAD_STATUS_ATOM = Atom.make("empty").pipe( Atom.withLabel("web-thread-status:empty"), ); -const EMPTY_MESSAGES_ATOM = Atom.make(EMPTY_MESSAGES).pipe( - Atom.withLabel("web-thread-messages:empty"), -); -const EMPTY_ACTIVITIES_ATOM = Atom.make(EMPTY_ACTIVITIES).pipe( - Atom.withLabel("web-thread-activities:empty"), -); -const EMPTY_PROPOSED_PLANS_ATOM = Atom.make(EMPTY_PROPOSED_PLANS).pipe( - Atom.withLabel("web-thread-proposed-plans:empty"), -); -const EMPTY_SESSION_ATOM = Atom.make(null).pipe( - Atom.withLabel("web-thread-session:empty"), -); export const activeEnvironmentIdAtom = Atom.make(null).pipe( Atom.keepAlive, @@ -72,32 +45,14 @@ export function useActiveEnvironmentId(): EnvironmentId | null { return useAtomValue(activeEnvironmentIdAtom); } -export function readActiveEnvironmentId(): EnvironmentId | null { - return appAtomRegistry.get(activeEnvironmentIdAtom); -} - export function setActiveEnvironmentId(environmentId: EnvironmentId | null): void { appAtomRegistry.set(activeEnvironmentIdAtom, environmentId); } -export function useProjectRefs(): ReadonlyArray { - return useAtomValue(environmentProjects.projectRefsAtom); -} - export function useThreadRefs(): ReadonlyArray { return useAtomValue(environmentThreadShells.threadRefsAtom); } -export function useEnvironmentProjectRefs( - environmentId: EnvironmentId | null, -): ReadonlyArray { - return useAtomValue( - environmentId === null - ? EMPTY_PROJECT_REFS_ATOM - : environmentProjects.environmentProjectRefsAtom(environmentId), - ); -} - export function useEnvironmentThreadRefs( environmentId: EnvironmentId | null, ): ReadonlyArray { @@ -184,38 +139,37 @@ export function useThread( return useMemo(() => mergeEnvironmentThread(detail, shell), [detail, shell]); } -export function useThreadMessages( - ref: ScopedThreadRef | null, -): ReadonlyArray { - return useAtomValue( - ref === null ? EMPTY_MESSAGES_ATOM : environmentThreadDetails.messagesAtom(ref), - ); -} - -export function useThreadActivities( - ref: ScopedThreadRef | null, -): ReadonlyArray { - return useAtomValue( - ref === null ? EMPTY_ACTIVITIES_ATOM : environmentThreadDetails.activitiesAtom(ref), - ); +export function readProject(ref: ScopedProjectRef): EnvironmentProject | null { + return appAtomRegistry.get(environmentProjects.projectAtom(ref)); } -export function useThreadProposedPlans( - ref: ScopedThreadRef | null, -): ReadonlyArray { - return useAtomValue( - ref === null ? EMPTY_PROPOSED_PLANS_ATOM : environmentThreadDetails.proposedPlansAtom(ref), - ); +export function readProjects(): ReadonlyArray { + return appAtomRegistry.get(environmentProjects.projectsAtom); } -export function useThreadSession(ref: ScopedThreadRef | null): OrchestrationSession | null { - return useAtomValue( - ref === null ? EMPTY_SESSION_ATOM : environmentThreadDetails.sessionAtom(ref), - ); -} +/** Resolves when the project event reaches the live client store. */ +export function waitForProject( + ref: ScopedProjectRef, + timeoutMs = 10_000, +): Promise { + const current = readProject(ref); + if (current !== null) return Promise.resolve(current); -export function readProject(ref: ScopedProjectRef): EnvironmentProject | null { - return appAtomRegistry.get(environmentProjects.projectAtom(ref)); + return new Promise((resolve, reject) => { + let unsubscribe: (() => void) | null = null; + const timeout = setTimeout(() => { + unsubscribe?.(); + reject(new Error("The project did not appear in the desktop app.")); + }, timeoutMs); + const finish = (project: EnvironmentProject | null) => { + if (project === null) return; + clearTimeout(timeout); + unsubscribe?.(); + resolve(project); + }; + unsubscribe = appAtomRegistry.subscribe(environmentProjects.projectAtom(ref), finish); + finish(readProject(ref)); + }); } export function readThreadShell(ref: ScopedThreadRef): EnvironmentThreadShell | null { @@ -268,28 +222,12 @@ export function readEnvironmentSupportsPinReorder(environmentId: EnvironmentId): ); } -export function readThreadDetail(ref: ScopedThreadRef): EnvironmentThread | null { - return appAtomRegistry.get(environmentThreadDetails.detailAtom(ref)); -} - export function readEnvironmentThreadRefs( environmentId: EnvironmentId, ): ReadonlyArray { return appAtomRegistry.get(environmentThreadShells.environmentThreadRefsAtom(environmentId)); } -export function readThreadRefs(): ReadonlyArray { - return appAtomRegistry.get(environmentThreadShells.threadRefsAtom); -} - export function readThreadShells(): ReadonlyArray { return appAtomRegistry.get(environmentThreadShells.threadShellsAtom); } - -export function findThreadRef(threadId: ThreadId): ScopedThreadRef | null { - return ( - appAtomRegistry - .get(environmentThreadShells.threadRefsAtom) - .find((ref) => ref.threadId === threadId) ?? null - ); -} diff --git a/apps/web/src/state/pullRequests.ts b/apps/web/src/state/pullRequests.ts index 1445ceca0328..939c4f3aea2d 100644 --- a/apps/web/src/state/pullRequests.ts +++ b/apps/web/src/state/pullRequests.ts @@ -1,5 +1,8 @@ import { useAtomValue } from "@effect/atom-react"; -import { createPullRequestEnvironmentAtoms } from "@t3tools/client-runtime/state/pull-requests"; +import { + createLinkedPullRequestDetailAtomFamily, + createPullRequestEnvironmentAtoms, +} from "@t3tools/client-runtime/state/pull-requests"; import type { EnvironmentId, PullRequestListInput, @@ -19,6 +22,8 @@ import { import { formatEnvironmentQueryError } from "./query"; export const pullRequestEnvironment = createPullRequestEnvironmentAtoms(connectionAtomRuntime); +export const linkedPullRequestDetailAtom = + createLinkedPullRequestDetailAtomFamily(connectionAtomRuntime); export interface EnvironmentQueryTarget { readonly environmentId: EnvironmentId; @@ -26,7 +31,7 @@ export interface EnvironmentQueryTarget { } interface MergedEnvironmentQueryView { - /** One entry per environment that has answered, in the order the targets were given. */ + /** One entry per query target that has answered, in the order the targets were given. */ readonly values: ReadonlyArray; /** The first environment that failed. Others may still have answered — this is not fatal. */ readonly error: string | null; @@ -73,11 +78,16 @@ function createMergedEnvironmentQuery( return function useMergedQuery(targets: ReadonlyArray>) { const key = JSON.stringify(targets); const view = useAtomValue(targets.length === 0 ? empty : family(key)); - const refresh = useCallback(() => { - for (const target of JSON.parse(key) as ReadonlyArray>) { - appAtomRegistry.refresh(atomFor(target)); - } - }, [key]); + const refresh = useCallback( + (override?: ReadonlyArray>) => { + const refreshTargets = + override ?? (JSON.parse(key) as ReadonlyArray>); + for (const target of refreshTargets) { + appAtomRegistry.refresh(atomFor(target)); + } + }, + [key], + ); return { ...view, refresh }; }; } @@ -113,7 +123,10 @@ export function usePullRequestListStats( targets: ReadonlyArray>, ): { readonly stats: ReadonlyArray | null; - readonly refresh: () => void; + readonly isPending: boolean; + readonly refresh: ( + targets?: ReadonlyArray>, + ) => void; } { const query = usePullRequestStatsQuery(targets); const stats = useMemo( @@ -125,5 +138,5 @@ export function usePullRequestListStats( ), [query.values], ); - return { stats, refresh: query.refresh }; + return { stats, isPending: query.isPending, refresh: query.refresh }; } diff --git a/apps/web/src/state/server.ts b/apps/web/src/state/server.ts index 1071d8209dfe..991abc3bc2e2 100644 --- a/apps/web/src/state/server.ts +++ b/apps/web/src/state/server.ts @@ -1,6 +1,7 @@ import { DEFAULT_SERVER_SETTINGS, type EditorId, + type EnvironmentTheme, type ServerConfig, type ServerConfigStreamEvent, type ServerLifecycleWelcomePayload, @@ -18,8 +19,15 @@ import { connectionAtomRuntime } from "../connection/runtime"; import { primaryEnvironmentIdAtom } from "./primaryEnvironment"; import { environmentSession } from "./session"; +// Opted in for every environment, not just the primary one. Only the primary +// environment's themes are rendered, but which environment is primary changes +// at runtime and the subscription payload is fixed when it is established -- +// gating on "primary right now" would leave a newly promoted environment with +// no themes until it reconnected. The set is capped server-side and stripped +// before caching, so following all of them costs a few KB per environment. export const serverEnvironment = createServerEnvironmentAtoms(connectionAtomRuntime, { initialConfigValueAtom: environmentSession.initialConfigValueAtom, + environmentThemes: true, }); export const environmentServerConfigsAtom = createEnvironmentServerConfigsAtom({ catalogValueAtom: environmentCatalog.catalogValueAtom, @@ -94,6 +102,18 @@ export const primaryServerKeybindingsConfigPathAtom = Atom.make( (get): string | null => get(primaryServerConfigAtom)?.keybindingsConfigPath ?? null, ).pipe(Atom.withLabel("web-primary-server-keybindings-config-path")); +const EMPTY_ENVIRONMENT_THEMES: ReadonlyArray = []; + +/** + * Palettes published by the primary environment's machine. Only the primary + * environment: a client follows the machine it is anchored to, not every + * environment it happens to be connected to. + */ +export const primaryServerEnvironmentThemesAtom = Atom.make( + (get): ReadonlyArray => + get(primaryServerConfigAtom)?.environmentThemes ?? EMPTY_ENVIRONMENT_THEMES, +).pipe(Atom.withLabel("web-primary-server-environment-themes")); + export const primaryServerObservabilityAtom = Atom.make( (get): ServerConfig["observability"] | null => get(primaryServerConfigAtom)?.observability ?? null, diff --git a/apps/web/src/state/use-atom-query-runner.ts b/apps/web/src/state/use-atom-query-runner.ts index 22f971e09a5d..691b1f43cb87 100644 --- a/apps/web/src/state/use-atom-query-runner.ts +++ b/apps/web/src/state/use-atom-query-runner.ts @@ -1,7 +1,7 @@ import { RegistryContext } from "@effect/atom-react"; import { executeAtomQuery, - type AtomCommandOptions, + type AtomQueryOptions, type AtomCommandResult, } from "@t3tools/client-runtime/state/runtime"; import { AsyncResult, type Atom } from "effect/unstable/reactivity"; @@ -9,12 +9,13 @@ import { useCallback, useContext } from "react"; export function useAtomQueryRunner( family: (target: T) => Atom.Atom>, - options?: string | AtomCommandOptions, + options?: string | AtomQueryOptions, ): (target: T) => Promise> { const registry = useContext(RegistryContext); const explicitLabel = typeof options === "string" ? options : options?.label; const reportFailure = typeof options === "string" ? true : (options?.reportFailure ?? true); const reportDefect = typeof options === "string" ? true : (options?.reportDefect ?? true); + const refresh = typeof options === "string" ? false : (options?.refresh ?? false); return useCallback( (target: T) => { @@ -23,8 +24,9 @@ export function useAtomQueryRunner( label: explicitLabel ?? atom.label?.[0] ?? "atom query", reportFailure, reportDefect, + refresh, }); }, - [explicitLabel, family, registry, reportDefect, reportFailure], + [explicitLabel, family, registry, refresh, reportDefect, reportFailure], ); } diff --git a/apps/web/src/terminal-links.test.ts b/apps/web/src/terminal-links.test.ts index 0f8e61962401..34c6c9830a0a 100644 --- a/apps/web/src/terminal-links.test.ts +++ b/apps/web/src/terminal-links.test.ts @@ -4,6 +4,7 @@ import { collectWrappedTerminalLinkLine, extractTerminalLinks, isTerminalLinkActivation, + isTerminalUrl, resolvePathLinkTarget, resolveWrappedTerminalLinkRange, wrappedTerminalLinkRangeIntersectsBufferLine, @@ -37,6 +38,24 @@ describe("extractTerminalLinks", () => { ]); }); + it("classifies uppercase schemes as URLs at activation time too", () => { + expect(isTerminalUrl("HTTPS://example.com/docs")).toBe(true); + expect(isTerminalUrl("Http://example.com")).toBe(true); + expect(isTerminalUrl("src/components/main.ts")).toBe(false); + expect(isTerminalUrl("httpsdocs/readme.md")).toBe(false); + }); + + it("finds URLs regardless of scheme casing", () => { + expect(extractTerminalLinks("open HTTPS://example.com/docs")).toEqual([ + { + kind: "url", + text: "HTTPS://example.com/docs", + start: 5, + end: 29, + }, + ]); + }); + it("trims trailing punctuation from links", () => { const line = "(https://example.com/docs), ./src/main.ts:12."; expect(extractTerminalLinks(line)).toEqual([ diff --git a/apps/web/src/terminal-links.ts b/apps/web/src/terminal-links.ts index a4eeda4279cc..1351a2a6c568 100644 --- a/apps/web/src/terminal-links.ts +++ b/apps/web/src/terminal-links.ts @@ -36,7 +36,7 @@ export interface WrappedTerminalLinkLine { segments: ReadonlyArray; } -const URL_PATTERN = /https?:\/\/[^\s"'`<>]+/g; +const URL_PATTERN = /https?:\/\/[^\s"'`<>]+/giu; const FILE_PATH_PATTERN = /(?:~\/|\.{1,2}\/|\/|[A-Za-z]:[\\/]|\\\\)[^\s"'`<>]+|[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)+(?::\d+){0,2}/g; const TRAILING_PUNCTUATION_PATTERN = /[.,;!?]+$/; @@ -80,7 +80,7 @@ function collectMatches( const trimmed = trimClosingDelimiters(raw); if (trimmed.length === 0) continue; - if (kind === "path" && /^https?:\/\//i.test(trimmed)) continue; + if (kind === "path" && isTerminalUrl(trimmed)) continue; const candidate: TerminalLinkMatch = { kind, @@ -172,6 +172,10 @@ export function extractTerminalLinks(line: string): TerminalLinkMatch[] { return [...urlMatches, ...pathMatches].toSorted((a, b) => a.start - b.start); } +export function isTerminalUrl(value: string): boolean { + return /^https?:\/\//iu.test(value); +} + export function collectWrappedTerminalLinkLine( bufferLineNumber: number, getLine: (bufferLineIndex: number) => TerminalBufferLineLike | null | undefined, diff --git a/apps/web/src/terminal/ghostty/surface.test.ts b/apps/web/src/terminal/ghostty/surface.test.ts index 45177a43a5be..11b3cc890fdb 100644 --- a/apps/web/src/terminal/ghostty/surface.test.ts +++ b/apps/web/src/terminal/ghostty/surface.test.ts @@ -20,7 +20,6 @@ import { resolveTerminalMouseTrackingState, shouldBlinkTerminalCursor, shouldReportTerminalMouse, - shouldShowTerminalLinkHover, terminalGridCellAt, terminalScrollbarGeometry, terminalScrollbarOffsetAtPointer, @@ -438,13 +437,6 @@ describe("application mouse reporting", () => { motionData: "\u001b[<35;8;4M", }); }); - - it("only shows link hover during mouse tracking when the link modifier is held", () => { - expect(shouldShowTerminalLinkHover(false, false)).toBe(true); - expect(shouldShowTerminalLinkHover(false, true)).toBe(true); - expect(shouldShowTerminalLinkHover(true, false)).toBe(false); - expect(shouldShowTerminalLinkHover(true, true)).toBe(true); - }); }); describe("terminal font resolution", () => { diff --git a/apps/web/src/terminal/ghostty/surface.ts b/apps/web/src/terminal/ghostty/surface.ts index f390947c4210..d4e503d19f00 100644 --- a/apps/web/src/terminal/ghostty/surface.ts +++ b/apps/web/src/terminal/ghostty/surface.ts @@ -483,13 +483,6 @@ export function isTerminalLinkPointerGesture( : event.ctrlKey && !event.metaKey; } -export function shouldShowTerminalLinkHover( - mouseTracking: boolean, - linkModifierActive: boolean, -): boolean { - return !mouseTracking || linkModifierActive; -} - export function ghosttyMouseButton(button: number): number | null { switch (button) { case 0: @@ -1409,10 +1402,7 @@ export class GhosttyTerminalSurface { private refreshHoveredLink(): void { const pointer = this.hoverPointer; - const link = - pointer && shouldShowTerminalLinkHover(this.core.isMouseTracking(), this.linkModifierActive) - ? this.linkAt(pointer.x, pointer.y) - : null; + const link = pointer && this.linkModifierActive ? this.linkAt(pointer.x, pointer.y) : null; this.setHoveredLink(link); } diff --git a/apps/web/src/themePalette.test.ts b/apps/web/src/themePalette.test.ts index a836f7e0c2eb..562ebc263764 100644 --- a/apps/web/src/themePalette.test.ts +++ b/apps/web/src/themePalette.test.ts @@ -39,6 +39,7 @@ import { themeColorToHex, toCanonicalThemeColor, THEME_FILE_VERSION, + singleAppearanceOf, } from "./themePalette"; function asHex(value: string): string { @@ -156,6 +157,9 @@ describe("theme files", () => { expect(contrastRatio(colors.textMuted, colors.canvas)).toBeLessThan(5.5); expect(contrastRatio(colors.mutedForeground, colors.muted)).toBeGreaterThanOrEqual(4.5); expect(contrastRatio(colors.placeholder, colors.surfaceRaised)).toBeGreaterThanOrEqual(4.5); + expect(contrastRatio(colors.placeholder, colors.surfaceRaised)).toBeLessThan( + contrastRatio(colors.text, colors.surfaceRaised), + ); expect(colors.secondaryLabel).toBe(colors.textMuted); expect(contrastRatio(colors.accentForeground, colors.accent)).toBeGreaterThanOrEqual(4.5); expect( @@ -1072,3 +1076,11 @@ describe("stored theme preferences", () => { invalidateCustomThemes(); }); }); + +describe("singleAppearanceOf", () => { + it("reports the only half a theme can claim, and null for a pair", () => { + const { variants: _pair, ...base } = T3_CHAT_THEME; + expect(singleAppearanceOf({ ...base, id: "x", appearance: "dark" })).toBe("dark"); + expect(singleAppearanceOf(T3_CHAT_THEME)).toBe(null); + }); +}); diff --git a/apps/web/src/themePalette.ts b/apps/web/src/themePalette.ts index 8402aeb2001b..458d4539e270 100644 --- a/apps/web/src/themePalette.ts +++ b/apps/web/src/themePalette.ts @@ -1,3 +1,4 @@ +import * as Equal from "effect/Equal"; import * as Schema from "effect/Schema"; import "culori/css"; import { converter, parse } from "culori/fn"; @@ -8,6 +9,7 @@ import { IRIS_THEME, OCEAN_THEME, T3_CHAT_THEME, + RESERVED_THEME_IDS, THEME_COLOR_ROLES, type ThemeAppearance, type ThemeColorRole, @@ -56,21 +58,16 @@ export type ThemeFile = Readonly<{ managed?: boolean; }>; -const RESERVED_THEME_IDS = new Set([ - "system", - "light", - "dark", - T3_CHAT_THEME_ID, - GROVE_THEME_ID, - OCEAN_THEME_ID, - EMBER_THEME_ID, - IRIS_THEME_ID, - LEGACY_T3_CHAT_DARK_THEME_ID, - "t3-grove", - "t3-ocean", - "t3-ember", - "t3-iris", -]); +// Reserved ids come from shared so the CLI, the server watcher, and this +// library cannot drift on what a published theme may be called. + +/** + * The environment's palettes are not saved: they are republished by the + * server on every change and would go stale the moment the machine's theme + * moved on. They ride the custom-theme listeners so every theme consumer + * already re-reads when they change. + */ +let environmentThemeDefinitions: ReadonlyArray = []; const customThemeListeners = new Set<() => void>(); type CustomThemeLibrarySnapshot = @@ -130,21 +127,28 @@ function parseThemeCollection(value: unknown): ThemeCollection | undefined { : undefined; } -function parseStoredThemeColors(value: unknown, appearance: ThemeAppearance): ThemeColors | null { - if (!isRecord(value)) return null; - - const colors: Partial> = { - ...getDefaultThemeColors(appearance), - }; - // Tolerate unknown roles and malformed values so themes saved by other - // builds (for example one that adds a new role) keep their remaining colors. +/** + * Tolerates unknown roles and malformed values so themes written by other + * builds (for example one that adds a new role) keep their remaining colors. + * The one canonicalization path for every externally supplied color record: + * stored themes, imported files, and environment-published themes. + */ +export function lenientThemeColorOverrides( + value: Readonly>, +): Partial> { + const overrides: Partial> = {}; for (const [role, color] of Object.entries(value)) { const normalized = toCanonicalThemeColor(color); if (THEME_COLOR_ROLE_SET.has(role) && normalized) { - colors[role as ThemeColorRole] = normalized; + overrides[role as ThemeColorRole] = normalized; } } - return colors as ThemeColors; + return overrides; +} + +function parseStoredThemeColors(value: unknown, appearance: ThemeAppearance): ThemeColors | null { + if (!isRecord(value)) return null; + return { ...getDefaultThemeColors(appearance), ...lenientThemeColorOverrides(value) }; } function parseStoredThemeVariants( @@ -244,6 +248,27 @@ export function getCustomThemes(): ReadonlyArray { return snapshot.status === "ready" ? snapshot.themes : []; } +export function getEnvironmentThemes(): ReadonlyArray { + return environmentThemeDefinitions; +} + +/** + * Returns whether anything changed, structurally: config snapshots arrive as + * fresh arrays on every reconnect, and a repaint for identical colors is the + * kind of wasted work users of this product notice. + */ +export function setEnvironmentThemes(themes: ReadonlyArray): boolean { + if (Equal.equals(environmentThemeDefinitions, themes)) return false; + environmentThemeDefinitions = themes; + notifyCustomThemeListeners(); + return true; +} + +/** Ids no published theme may occupy: appearance keywords and built-in ids. */ +export function isReservedThemeId(themeId: string): boolean { + return RESERVED_THEME_IDS.has(themeId); +} + export function getStoredCustomThemeCollection( collectionId: string, ): ReadonlyArray { @@ -892,7 +917,7 @@ export function createVividThemeColors( solveOklchLightness(textBase, surfaceRgb, 4.6, dark ? "lighter" : "darker"), ); const mutedForeground = foregroundOn(mutedRgb); - const placeholder = foregroundOn(surfaceRaisedRgb); + const placeholder = themeRgbToThemeColor(readableThemeText(surfaceRaisedRgb, textRgb, 1, 4.6)); const actionHover: ThemeOklch = { ...action, L: action.L + (dark ? 0.06 : -0.06) }; @@ -1424,6 +1449,9 @@ export function getThemeDefinition(theme: ThemePreference): ThemeDefinition | nu return ( BUILT_IN_THEME_DEFINITIONS.find((definition) => definition.id === themeId) ?? getCustomThemes().find((definition) => definition.id === themeId) ?? + // Resolved last so a theme the user saved always wins over one the + // machine happens to publish under the same id. + environmentThemeDefinitions.find((definition) => definition.id === themeId) ?? null ); } @@ -1437,6 +1465,17 @@ export function themeAllowsSidebarArtwork(theme: ThemePreference): boolean { ); } +/** + * Which half a theme can claim, or null when it renders both appearances. + * Selecting a single-appearance theme as the base preference would clear the + * light/dark mix and leave the appearance tiles disagreeing with what is on + * screen, so every path that selects a theme has to make the same call. + */ +export function singleAppearanceOf(theme: ThemeDefinition): ThemeAppearance | null { + const modes = getThemeModes(theme); + return modes.length === 1 ? modes[0]! : null; +} + export function getThemeColorsForMode( theme: ThemeDefinition, mode: ThemeAppearance, diff --git a/apps/web/src/threadSync.test.ts b/apps/web/src/threadSync.test.ts index ba91e33078f7..dbdb7bb0834f 100644 --- a/apps/web/src/threadSync.test.ts +++ b/apps/web/src/threadSync.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; -import { resolveThreadSyncPhase, threadSyncLabel } from "./threadSync"; +import { resolveThreadSyncPhase } from "./threadSync"; describe("resolveThreadSyncPhase", () => { it("loads when only shell data is available", () => { @@ -40,10 +40,3 @@ describe("resolveThreadSyncPhase", () => { ).toBeNull(); }); }); - -describe("threadSyncLabel", () => { - it("uses the same loading and syncing language as mobile", () => { - expect(threadSyncLabel("loading")).toBe("Loading messages..."); - expect(threadSyncLabel("syncing")).toBe("Syncing messages..."); - }); -}); diff --git a/apps/web/src/timestampFormat.test.ts b/apps/web/src/timestampFormat.test.ts index 7cc61e02c72e..578587510db1 100644 --- a/apps/web/src/timestampFormat.test.ts +++ b/apps/web/src/timestampFormat.test.ts @@ -6,10 +6,7 @@ import { formatExpiresInLabel, formatRelativeTime, formatRelativeTimeLabel, - formatRelativeTimeUntil, - formatRelativeTimeUntilLabel, formatShortTimestamp, - formatTimestamp, getRelativeTimeState, getTimestampFormatOptions, resolveTimestampLocale, @@ -76,33 +73,6 @@ describe("resolveTimestampLocale", () => { }); }); -describe("formatRelativeTimeUntilLabel", () => { - beforeEach(() => { - vi.useFakeTimers(); - vi.setSystemTime(new Date("2026-04-07T12:00:00.000Z")); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - it("returns Expired when the instant is in the past", () => { - expect(formatRelativeTimeUntilLabel("2026-04-07T11:59:00.000Z")).toBe("Expired"); - }); - - it("formats seconds remaining", () => { - expect(formatRelativeTimeUntilLabel("2026-04-07T12:00:45.000Z")).toBe("45s left"); - }); - - it("formats minutes remaining", () => { - expect(formatRelativeTimeUntilLabel("2026-04-07T12:15:00.000Z")).toBe("15m left"); - }); - - it("formats hours remaining", () => { - expect(formatRelativeTimeUntilLabel("2026-04-07T18:00:00.000Z")).toBe("6h left"); - }); -}); - describe("formatExpiresInLabel", () => { beforeEach(() => { vi.useFakeTimers(); @@ -196,10 +166,6 @@ describe("formatDayAwareTimestamp", () => { }); describe("invalid timestamp inputs", () => { - it("returns an empty timestamp instead of throwing", () => { - expect(formatTimestamp("not-a-date", "12-hour")).toBe(""); - }); - it("returns an empty short timestamp instead of throwing", () => { expect(formatShortTimestamp("not-a-date", "12-hour")).toBe(""); }); @@ -218,11 +184,6 @@ describe("invalid timestamp inputs", () => { expect(formatElapsedDurationLabel("not-a-date")).toBe(""); }); - it("returns an empty relative time until label instead of a NaN label", () => { - expect(formatRelativeTimeUntil("not-a-date")).toBeNull(); - expect(formatRelativeTimeUntilLabel("not-a-date")).toBe(""); - }); - it("returns an empty expires-in label instead of a NaN label", () => { expect(formatExpiresInLabel("not-a-date")).toBe(""); }); diff --git a/apps/web/src/timestampFormat.ts b/apps/web/src/timestampFormat.ts index c1c30a544573..72ba711a1ade 100644 --- a/apps/web/src/timestampFormat.ts +++ b/apps/web/src/timestampFormat.ts @@ -78,12 +78,6 @@ export function parseTimestampDate(isoDate: string): Date | null { return Number.isNaN(date.getTime()) ? null : date; } -export function formatTimestamp(isoDate: string, timestampFormat: TimestampFormat): string { - const date = parseTimestampDate(isoDate); - if (!date) return ""; - return getTimestampFormatter(timestampFormat, true).format(date); -} - // Deliberately not the host locale: the tooltip's ordinal suffix and // day-before-month order below are English, so a localized month alone would // read "4th Juni 2026". Localizing the whole label is a separate change. @@ -228,31 +222,6 @@ export function formatElapsedDurationLabel(isoDate: string, nowMs: number = Date return `${days}d`; } -/** - * Relative time until an ISO instant (e.g. expiry). Mirrors {@link formatRelativeTime} but for future times. - */ -export function formatRelativeTimeUntil(isoDate: string): RelativeTimeParts | null { - const date = parseTimestampDate(isoDate); - if (!date) return null; - const diffMs = date.getTime() - Date.now(); - if (diffMs <= 0) return { value: "Expired", suffix: null }; - const seconds = Math.floor(diffMs / 1000); - if (seconds < 5) return { value: "Soon", suffix: null }; - if (seconds < 60) return { value: `${seconds}s`, suffix: "left" }; - const minutes = Math.floor(seconds / 60); - if (minutes < 60) return { value: `${minutes}m`, suffix: "left" }; - const hours = Math.floor(minutes / 60); - if (hours < 24) return { value: `${hours}h`, suffix: "left" }; - const days = Math.floor(hours / 24); - return { value: `${days}d`, suffix: "left" }; -} - -export function formatRelativeTimeUntilLabel(isoDate: string): string { - const relative = formatRelativeTimeUntil(isoDate); - if (!relative) return ""; - return relative.suffix ? `${relative.value} ${relative.suffix}` : relative.value; -} - /** * Countdown for a future instant (e.g. link expiry): "Expires in 4m 12s", with second precision under one hour. * Pass `nowMs` when a parent tick drives re-renders so the diff matches that snapshot. diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts index 45a8539a1517..8638cade733a 100644 --- a/apps/web/src/types.ts +++ b/apps/web/src/types.ts @@ -1,5 +1,7 @@ import type { + ChatFileAttachment as ContractChatFileAttachment, ChatImageAttachment as ContractChatImageAttachment, + ChatUnknownAttachment as ContractChatUnknownAttachment, OrchestrationCheckpointFile, OrchestrationCheckpointSummary, OrchestrationLatestTurn, @@ -15,6 +17,9 @@ import type { EnvironmentThread, EnvironmentThreadShell, } from "@t3tools/client-runtime/state/shell"; +import { videoMimeType } from "@t3tools/shared/video"; + +export { videoMimeType } from "@t3tools/shared/video"; export type SessionPhase = "disconnected" | "connecting" | "ready" | "running"; export const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access"; @@ -35,7 +40,31 @@ export interface ChatImageAttachment extends ContractChatImageAttachment { readonly previewUrl?: string; } -export type ChatAttachment = ChatImageAttachment; +export interface ChatFileAttachment extends ContractChatFileAttachment { + readonly previewUrl?: string; + readonly downloadable?: boolean; +} + +// Attachment types this build does not know pass through with the contract +// shape. The UI renders them as inert rows so a newer server cannot crash an +// older client. +export type ChatUnknownAttachment = ContractChatUnknownAttachment; + +export type ChatAttachment = ChatImageAttachment | ChatFileAttachment | ChatUnknownAttachment; + +// The union has an open member (`type: string`), so a literal comparison does +// not narrow. Use these guards wherever type-specific fields are read. +export function isImageAttachment(attachment: ChatAttachment): attachment is ChatImageAttachment { + return attachment.type === "image"; +} + +export function isFileAttachment(attachment: ChatAttachment): attachment is ChatFileAttachment { + return attachment.type === "file"; +} + +export function isVideoAttachment(attachment: ChatFileAttachment): boolean { + return videoMimeType(attachment) !== null; +} export interface ChatMessage extends Omit { readonly attachments?: ReadonlyArray | undefined; diff --git a/apps/web/src/vendor/mdast-find-and-replace.ts b/apps/web/src/vendor/mdast-find-and-replace.ts new file mode 100644 index 000000000000..2f16ab202ba0 --- /dev/null +++ b/apps/web/src/vendor/mdast-find-and-replace.ts @@ -0,0 +1,90 @@ +/* + * Adapted from mdast-util-find-and-replace: + * https://github.com/syntax-tree/mdast-util-find-and-replace/blob/main/lib/index.js + * + * The MIT License + * + * Copyright (c) Titus Wormer + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +export type MarkdownNode = { + type: string; + value?: string; + children?: MarkdownNode[]; + url?: string; + data?: unknown; +}; + +export type TextMatch = { + index: number; + input: string; +}; + +/** The dependency-free subset of mdast-util-find-and-replace used by PR autolinks. */ +export function findAndReplaceText( + tree: MarkdownNode, + find: RegExp, + replace: (matched: string, match: TextMatch) => MarkdownNode | false, + ignoredTypes: ReadonlySet, +): void { + visit(tree); + + function visit(node: MarkdownNode): void { + if (node.children === undefined) return; + for (let childIndex = 0; childIndex < node.children.length; childIndex += 1) { + const child = node.children[childIndex]!; + if (ignoredTypes.has(child.type)) continue; + if (child.type !== "text" || child.value === undefined) { + visit(child); + continue; + } + + const replacements: MarkdownNode[] = []; + let start = 0; + let changed = false; + find.lastIndex = 0; + let match = find.exec(child.value); + while (match !== null) { + const position = match.index; + const replacement = replace(match[0], { index: position, input: match.input }); + if (replacement === false) { + find.lastIndex = position + 1; + } else { + if (start < position) { + replacements.push({ type: "text", value: child.value.slice(start, position) }); + } + replacements.push(replacement); + start = position + match[0].length; + changed = true; + } + if (!find.global) break; + match = find.exec(child.value); + } + + if (!changed) continue; + if (start < child.value.length) { + replacements.push({ type: "text", value: child.value.slice(start) }); + } + node.children.splice(childIndex, 1, ...replacements); + childIndex += replacements.length - 1; + } + } +} diff --git a/apps/web/src/versionSkew.test.ts b/apps/web/src/versionSkew.test.ts index 949b242f0c6e..bb1febf9850a 100644 --- a/apps/web/src/versionSkew.test.ts +++ b/apps/web/src/versionSkew.test.ts @@ -1,4 +1,5 @@ import { EnvironmentId } from "@t3tools/contracts"; +import type { ServerUpdateState } from "@t3tools/client-runtime/state/server"; import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; // Pinned so the direction cases below read as fixed versions instead of @@ -8,9 +9,10 @@ vi.mock("./branding", () => branding); import { APP_VERSION } from "./branding"; import { - appendVersionMismatchHint, buildVersionMismatchDismissalKey, + dismissServerUpdateFailure, dismissVersionMismatch, + isServerUpdateFailureDismissed, isVersionMismatchDismissed, resolveServerConfigVersionMismatch, resolveServerSelfUpdateCapability, @@ -26,6 +28,39 @@ describe("versionSkew", () => { branding.APP_VERSION = "0.0.34"; }); + it("dismisses only the current failed attempt without clearing its retry state", () => { + const failure = { + status: "failed", + stage: "downloading", + fromVersion: "0.0.33", + targetVersion: "0.0.34", + message: "Download failed.", + } as const satisfies ServerUpdateState; + const retryFailure = { ...failure }; + const otherEnvironmentFailure = { ...failure }; + + dismissServerUpdateFailure(failure); + + expect(isServerUpdateFailureDismissed(failure)).toBe(true); + expect(failure.status).toBe("failed"); + expect(failure.message).toBe("Download failed."); + expect(isServerUpdateFailureDismissed(retryFailure)).toBe(false); + expect(isServerUpdateFailureDismissed(otherEnvironmentFailure)).toBe(false); + }); + + it("does not dismiss an update that is still running", () => { + const running = { + status: "running", + stage: "resuming", + fromVersion: "0.0.33", + targetVersion: "0.0.34", + } as const satisfies ServerUpdateState; + + dismissServerUpdateFailure(running); + + expect(isServerUpdateFailureDismissed(running)).toBe(false); + }); + it("does not warn when versions match", () => { expect(resolveVersionMismatch(APP_VERSION)).toBeNull(); }); @@ -50,6 +85,25 @@ describe("versionSkew", () => { expect(resolveVersionMismatch("0.0.34")).toBeNull(); }); + it.each(["0.0.34-nightly.20260823.1124", "0.0.34-nightly.20260824.1124"])( + "warns when nightly server %s is behind a nightly client on the same release", + (serverVersion) => { + branding.APP_VERSION = "0.0.34-nightly.20260824.1125"; + + expect(resolveVersionMismatch(serverVersion)).toEqual({ + clientVersion: "0.0.34-nightly.20260824.1125", + serverVersion, + hint: MISMATCH_HINT, + }); + }, + ); + + it("does not warn when a nightly server is ahead on the same release", () => { + branding.APP_VERSION = "0.0.34-nightly.20260824.1125"; + + expect(resolveVersionMismatch("0.0.34-nightly.20260824.1126")).toBeNull(); + }); + it("treats a nightly server built past the client as ahead, not skew", () => { expect(resolveVersionMismatch("0.0.35-nightly.20260818.1124")).toBeNull(); }); @@ -120,14 +174,6 @@ describe("versionSkew", () => { ).toBe(false); }); - it("appends a hint to connection errors when the server is behind", () => { - const mismatch = resolveVersionMismatch("0.0.33"); - - expect(appendVersionMismatchHint("Socket closed.", mismatch)).toBe( - `Socket closed. Hint: ${MISMATCH_HINT}`, - ); - }); - it("reads desktop-managed update capabilities from config descriptors", () => { expect( resolveServerSelfUpdateCapability({ diff --git a/apps/web/src/versionSkew.ts b/apps/web/src/versionSkew.ts index de40141fc836..20f451851fdd 100644 --- a/apps/web/src/versionSkew.ts +++ b/apps/web/src/versionSkew.ts @@ -1,4 +1,5 @@ import type { EnvironmentId, ServerConfig, ServerSelfUpdateCapability } from "@t3tools/contracts"; +import type { ServerUpdateState } from "@t3tools/client-runtime/state/server"; import { compareSemverVersions, parseSemver } from "@t3tools/shared/semver"; import * as Schema from "effect/Schema"; @@ -13,6 +14,18 @@ export interface VersionMismatch { export const VERSION_MISMATCH_DISMISSALS_STORAGE_KEY = "t3code:version-mismatch-dismissals:v1"; +// Runtime failures retain their identity until the next attempt. Dismiss only +// that attempt, across chat remounts, without clearing the error in Settings. +const dismissedServerUpdateFailures = new WeakSet(); + +export function isServerUpdateFailureDismissed(state: ServerUpdateState): boolean { + return state.status === "failed" && dismissedServerUpdateFailures.has(state); +} + +export function dismissServerUpdateFailure(state: ServerUpdateState): void { + if (state.status === "failed") dismissedServerUpdateFailures.add(state); +} + const VersionMismatchDismissalsSchema = Schema.Struct({ keys: Schema.Array(Schema.String), }); @@ -33,13 +46,11 @@ function versionCore(version: string): string { * The skew a user can act on: the connected server runs an older T3 Code than * this client, so the server is the side that needs updating. * - * Versions compare as semver on their core `major.minor.patch` only. Nightlies - * are `-nightly..` builds of the release they precede, so a - * stable client on a nightly server (or the reverse, at the same core) shares a - * wire contract and is not skew. A server *ahead* of the client is not skew - * either: the client is the stale side, and every consumer of this result tells - * the user to update their server. Versions that do not parse as semver fall - * back to plain string inequality. + * Two nightly builds compare their full versions, including the date and run. + * Other combinations compare their core `major.minor.patch` only, so a stable + * build and a nightly build with the same core do not cause an update warning. + * A server ahead of the client does not need an update. Versions that do not + * parse as semver fall back to plain string inequality. */ export function resolveVersionMismatch( serverVersion: string | null | undefined, @@ -52,9 +63,15 @@ export function resolveVersionMismatch( const clientCore = versionCore(normalizedClientVersion); const serverCore = versionCore(normalizedServerVersion); + const compareNightlyBuilds = + parseSemver(normalizedClientVersion)?.prerelease[0] === "nightly" && + parseSemver(normalizedServerVersion)?.prerelease[0] === "nightly"; const serverIsBehind = parseSemver(clientCore) && parseSemver(serverCore) - ? compareSemverVersions(serverCore, clientCore) < 0 + ? compareSemverVersions( + compareNightlyBuilds ? normalizedServerVersion : serverCore, + compareNightlyBuilds ? normalizedClientVersion : clientCore, + ) < 0 : normalizedServerVersion !== normalizedClientVersion; if (!serverIsBehind) { return null; @@ -155,17 +172,3 @@ export function dismissVersionMismatch(dismissalKey: string | null | undefined): keys: [...document.keys, dismissalKey], }); } - -export function appendVersionMismatchHint( - message: string | null | undefined, - mismatch: VersionMismatch | null | undefined, -): string | null { - const normalizedMessage = normalizeVersion(message); - if (!normalizedMessage) { - return mismatch?.hint ?? null; - } - if (!mismatch) { - return normalizedMessage; - } - return `${normalizedMessage} Hint: ${mismatch.hint}`; -} diff --git a/apps/web/src/vscodeThemeImport.test.ts b/apps/web/src/vscodeThemeImport.test.ts index e4fcdb907abb..59f732bf4a12 100644 --- a/apps/web/src/vscodeThemeImport.test.ts +++ b/apps/web/src/vscodeThemeImport.test.ts @@ -90,6 +90,28 @@ describe("VS Code theme import", () => { expect(theme.colors.sidebarRowSelected).not.toBe(theme.colors.sidebar); }); + it("keeps a fallback placeholder dimmer than entered text", () => { + const theme = parseVsCodeThemeFile({ + name: "Dark placeholder fallback", + type: "dark", + colors: { + "editor.background": "#1e1e2e", + "editor.foreground": "#cdd6f4", + "input.placeholderForeground": "#cdd6f473", + }, + }); + + expect( + contrastRatio(theme.colors.placeholder, theme.colors.surfaceRaised), + ).toBeGreaterThanOrEqual(4.5); + expect(contrastRatio(theme.colors.placeholder, theme.colors.canvas)).toBeGreaterThanOrEqual( + 4.5, + ); + expect(contrastRatio(theme.colors.placeholder, theme.colors.canvas)).toBeLessThan( + contrastRatio(theme.colors.text, theme.colors.canvas), + ); + }); + it("fills every role the file omits with a readable derived value", () => { const theme = parseVsCodeThemeFile(VSCODE_DARK); const colors = getThemeColorsForMode(theme, "dark")!; diff --git a/assets/README.md b/assets/README.md index 8c53266bfe7f..c1a16dde24b2 100644 --- a/assets/README.md +++ b/assets/README.md @@ -48,3 +48,17 @@ Verify every result is 1024×1024 and has the classic macOS safe area: an 824×8 ``` Do not edit the generated PNG or ICO files directly. + +## Android adaptive foreground + +`apps/mobile/assets/android-icon-foreground.svg` is the source of truth for the foreground used by +the normal Android adaptive launcher icon. Export its paired PNG after changing it: + +```sh +rsvg-convert -w 432 -h 432 \ + -o apps/mobile/assets/android-icon-foreground.png \ + apps/mobile/assets/android-icon-foreground.svg +``` + +The foreground must remain transparent and keep the T3 mark inside Android's adaptive-icon safe +zone. `android-icon-mark.png` remains a flat silhouette for Android's monochrome themed icon. diff --git a/docs/README.md b/docs/README.md index 622d81064387..2e2e55fbbadc 100644 --- a/docs/README.md +++ b/docs/README.md @@ -9,11 +9,12 @@ - [Review usage](./user/usage.md) - [Customize a project icon](./user/project-settings.md) - [Mobile appearance](./user/mobile-appearance.md) +- [Environment themes](./user/environment-theme.md) - [Remote access](./user/remote-access.md) - [Keeping app and server in sync](./user/updating.md) - [Source control integrations](./user/source-control.md) - [Background service (Linux)](./user/background-service.md) -- Providers: [Codex](./user/providers-codex.md) · [Claude](./user/providers-claude.md) +- Providers: [Codex](./user/providers-codex.md) · [Claude](./user/providers-claude.md) · [OpenCode](./user/providers-opencode.md) Mobile app: [apps/mobile/README.md](../apps/mobile/README.md) @@ -29,10 +30,12 @@ policy in [CONTRIBUTING.md](../CONTRIBUTING.md); agent rules in [AGENTS.md](../A - [Glossary](./internals/glossary.md) - [Scripts](./internals/scripts.md) - [Connection runtime](./internals/connection-runtime.md) +- [Voice input](./internals/voice-input.md) - [Providers](./internals/providers.md) - [Remote environments](./internals/remote.md) - [Server updates](./internals/server-updates.md) - [Resource telemetry](./internals/resource-telemetry.md) +- [Product analytics](./internals/product-analytics.md) - [Environment auth](./internals/environment-auth.md) - [T3 Connect](./internals/t3-connect.md) - [CI gates](./internals/ci.md) diff --git a/docs/internals/ci.md b/docs/internals/ci.md index bb38fd79ac28..d67c9fbe081b 100644 --- a/docs/internals/ci.md +++ b/docs/internals/ci.md @@ -8,7 +8,8 @@ and pushes to `main`: - **Check**: `vp check` (format and lint; this repo sets `typeCheck: false` in its lint options), then `vpr typecheck` for the workspace type check. The same job builds the desktop pipeline (`vp run build:desktop`) and verifies the preload bundle exists and - still exports its expected symbols. + uses only imports that Electron's sandbox can load. The verifier parses imports, then executes the + trusted artifact with controlled bridge stubs to confirm that its required APIs are callable. - **Test**: `vp run test` across the workspace. - **Mobile Native Static Analysis**: `vp run lint:mobile` on macOS, wrapping `scripts/mobile-native-static-check.ts`. A cheap Linux **Mobile Native Changes** job gates it: diff --git a/docs/internals/connection-runtime.md b/docs/internals/connection-runtime.md index 46fe0c82716a..1b686c365770 100644 --- a/docs/internals/connection-runtime.md +++ b/docs/internals/connection-runtime.md @@ -138,6 +138,19 @@ connection policy. `EnvironmentOwnedDataCleanup` is part of this contract: on removal the registry clears its cache and calls the platform implementation, so web clears composer drafts and mobile clears drafts plus the thread outbox. +Mobile cloud sign-out first saves relay drafts and queued messages in the local +composer store under the owning account. These saved copies retain attachment +files during cleanup and remain outside the active composer and upload queue. +Signing back into that account restores them before relay credentials activate. +Directly paired environments keep their drafts and outbox when cloud sign-out runs. + +Mobile composer attachments upload over HTTP while their environment is connected, +with at most three concurrent transfers. Drafts retain local image data or an owned +file URI alongside the pending upload ID. Sending verifies and reuses that ID, or +uploads the local bytes again if it expired. Disconnecting cancels active transfers +without discarding drafts; reconnecting resumes preparation. Older servers without +attachment-upload support continue to receive inline images. + ## Source Boundaries Applications must import explicit package subpaths; the package intentionally diff --git a/docs/internals/environment-auth.md b/docs/internals/environment-auth.md index 5f4f5b6e9607..ac54e60dfcbb 100644 --- a/docs/internals/environment-auth.md +++ b/docs/internals/environment-auth.md @@ -28,6 +28,40 @@ managed relay connectivity: The desktop bootstrap credential and command-line administrative bootstrap credentials additionally grant `access:read access:write relay:write`. +## Media preview access + +Clients with `orchestration:read` can request a `media-file` URL through `assets.createUrl` for +supported images and videos anywhere the environment's server account can read. A thread ID +supplies the workspace for relative paths; absolute paths refer to the environment host, not the +client. This follows the environment-wide authorization model rather than introducing per-project +filesystem permissions. + +[`AssetAccess.ts`](../../apps/server/src/assets/AssetAccess.ts) resolves symlinks, requires a regular +file, and validates the resolved file's literal extension. It opens the file and signs its canonical +path and device/inode identity for one hour. The token grants access to that exact file, not adjacent +files or its containing directory. Serving rechecks the canonical path, media type, and opened +descriptor's identity, then streams full or partial responses from that descriptor. Replacing a +file atomically requires a freshly signed URL; editing it in place does not. The existing workspace +boundary still applies to HTML, PDF, and other workspace previews. Uploaded attachments keep their +separate asset resource. + +Signed asset URLs are bearer credentials. Anyone who obtains a URL and can reach the environment +can fetch that file until it expires. Clients should copy the authored reference, not the temporary +URL. Responses use `nosniff`; SVG responses retain their restrictive sandbox policy. Video reads +support byte ranges so playback does not require a complete download first. + +Host videos can change in place, so their responses use `private, no-store` and omit `ETag` +and `Last-Modified`. File metadata cannot prove byte-for-byte identity for `If-Range`; advertising +those validators would encourage native players to send conditional seeks that require a full +response. Ordinary range requests receive partial responses. An explicitly supplied `If-Range` +still falls back to a full response because no strong validator is available. Host image previews +keep their private cache policy and weak metadata validators. + +The server serves media in place without importing it into attachment storage. Deletion makes +future server reads fail, though an already loaded client or its cache can retain bytes. Native +viewers may use temporary client-side files for display or explicit sharing; those are not durable +environment copies. + ## Authentication Flows ### Browser Session @@ -84,7 +118,9 @@ that sends a `DPoP` header has its proof verified by `verifyRequestDpopProof`; the resulting JWK thumbprint is stored on the session, which is then issued with method `dpop-access-token` and a one-hour TTL instead of the bearer default. An invalid proof gets a DPoP challenge header and a credential error rather than a -bearer token. +bearer token. Newer servers include a safe `dpopFailureReason` category in that +error. When an older server omits the category, clients mention clock skew as +one possible cause rather than presenting it as confirmed. `dpop-access-token` is advertised alongside `browser-session-cookie` and `bearer-access-token` in the descriptor's `sessionMethods` diff --git a/docs/internals/glossary.md b/docs/internals/glossary.md index da16f74d339f..c1b4251f91d0 100644 --- a/docs/internals/glossary.md +++ b/docs/internals/glossary.md @@ -11,6 +11,7 @@ This is a living glossary for T3 Code. It explains what common terms mean in thi - [Orchestration](#orchestration) - [Provider runtime](#provider-runtime) - [Checkpointing](#checkpointing) +- [Appearance](#appearance) ## Concepts @@ -116,6 +117,10 @@ Controls how assistant text reaches the thread timeline. In [the contracts][1], A point-in-time view of state. The word is used in multiple layers, including orchestration, provider, and checkpointing. See [ProjectionSnapshotQuery.ts][10], [ProviderAdapter.ts][15], and [CheckpointStore.ts][19]. +#### Model manifest + +The per-driver list of current model slugs that decides which models land in the model picker's legacy section. Bundled at `apps/server/src/provider/model-manifest.json` and refreshed at runtime from the same file on `main`, so classification updates ship as commits instead of releases. See the [provider architecture][16] model manifest section. + ### Checkpointing Checkpointing captures workspace state over time so the app can diff turns and restore earlier points. The main pieces are [CheckpointStore.ts][19], [CheckpointDiffQuery.ts][20], and [CheckpointReactor.ts][6]. @@ -140,6 +145,21 @@ The patch difference between two checkpoints. Query logic lives in [CheckpointDi The file patch and changed-file summary for one turn. It is usually computed in [CheckpointDiffQuery.ts][20], represented in [the contracts][1], and recorded into thread state by [projector.ts][4]. +### Appearance + +#### Environment theme + +A theme an environment's machine publishes for clients to follow, one file per theme under `themes/` in that environment's state directory; the filename is the theme id. [environmentTheme.ts][25] watches the directory and streams the set over `subscribeServerConfig`; clients render each as a library card, generating a full palette when the file carries seed colors and using the palette directly when it is a standard exported theme file. A desktop that retints its apps when the system theme changes rewrites its file, so T3 Code follows along without a restart. See [environment-theme.md][26]. + +#### Default theme + +The environment's theme, held in its `settings.json` as `defaultTheme` (with `defaultThemeSetAt` +as the set-generation) and set with `t3 theme set `. Web and desktop clients apply each set +once — live when connected, on the next connect otherwise — so setting it switches them, while a +theme a user picks in Settings afterwards sticks until the next set; mobile keeps its own +appearance settings. Naming a published [environment theme](#environment-theme) is how a desktop +ships T3 Code already matching it. + ## Practical Shortcuts - If you see `requested`, think "intent recorded". @@ -179,3 +199,5 @@ The file patch and changed-file summary for one turn. It is usually computed in [22]: ../../apps/server/src/checkpointing/Utils.ts [23]: ../../apps/server/src/checkpointing/Diffs.ts [24]: ./overview.md +[25]: ../../apps/server/src/environmentTheme.ts +[26]: ../user/environment-theme.md diff --git a/docs/internals/mobile-development.md b/docs/internals/mobile-development.md new file mode 100644 index 000000000000..e8923a4bc844 --- /dev/null +++ b/docs/internals/mobile-development.md @@ -0,0 +1,50 @@ +# Mobile development lifecycle + +Ordinary component changes use Metro Fast Refresh. The connection runtime uses +a stable development-only atom runtime whose writable layer atom receives the +newly evaluated Effect layer. Its module accepts the update only after installing +that layer. Existing subscribers observe the new context, and Effect releases the +previous runtime scope without restarting React Navigation. Production uses an +ordinary `Atom.runtime` without this hot-update boundary. + +Replacing the atom registry itself disposes its old nodes; replacing the managed +runtime starts its asynchronous disposal. These modules use normal Metro update +propagation. The app does not call `DevSettings.reload` during their replacement. +React can still reset state when its normal Fast Refresh rules require it. + +Do not reset the shared registry to clean up a connection-runtime edit: registry +reset removes listeners from unedited mounted consumers. Do not self-accept a +runtime module while leaving importers attached to its old implementation. A +hot-update boundary must install fresh behavior through the existing reactive +dependencies. These boundaries do not make every module-level atom family in the +app hot-swappable; new runtime singletons still need explicit ownership. Editing +the registry or managed runtime can still rebuild a much larger dependency graph +than an ordinary component or connection-runtime edit. + +Environment supervisor scopes are children of the connection registry scope. +The registry's per-environment map supports targeted shutdown, but it cannot be +the sole owner: a supervisor created after that map's finalizer has run must +still inherit the closed parent scope. Otherwise interrupted startup or runtime +replacement can leave a session and WebSocket alive outside the current registry. + +The compact Home list owns its minute-based presentation clock in a focus effect. +Blur clears the interval without a render-driving focus subscription; focus +refreshes the clock immediately. Other prop or state changes can still render +the hidden list. Exact snooze-expiry timers remain active, and the visible iPad +sidebar keeps its own minute updates. + +Connection and runtime projections are shared per environment. Thread selection +consumers should read those atoms instead of repeatedly parsing the same socket +URL or constructing new connection objects during each render. + +The Uniwind dependency patch still recompiles CSS on Metro updates so newly used +classes are discovered. It fingerprints the generated native stylesheet and +theme list, then skips development-only global invalidation when that output is +unchanged. A changed stylesheet or theme list still resets the style caches and +notifies subscribers. The digest is committed only after initialization succeeds. +Web and production retain their existing initialization behavior. + +After installing or changing the Uniwind patch, restart Metro once with +`vp run dev:client:reset` from `apps/mobile`. pnpm gives patched packages new +filesystem paths, and cached transforms can otherwise retain references to the +previous package. Ordinary development starts should retain the transform cache. diff --git a/docs/internals/mobile-navigation.md b/docs/internals/mobile-navigation.md new file mode 100644 index 000000000000..61b97ab6a86f --- /dev/null +++ b/docs/internals/mobile-navigation.md @@ -0,0 +1,123 @@ +# Mobile navigation + +The iOS Home and thread routes share the root native stack in +[`Stack.tsx`](../../apps/mobile/src/Stack.tsx). Keeping them in one navigation +controller lets UIKit animate the header between routes. The iPad sidebar owns +a separate, single-screen stack; Android uses its own in-flow headers. + +Home and the iPad sidebar render their brand and connection status through +`headerTitle`, with `Threads` retained as the route title. The editor-style native +bar aligns that title on the leading side. Do not model the brand as a toolbar +button: on iOS 26.5 UIKit morphs a background-free custom leading item's rectangle +into the next screen's glass back button, even with distinct item identifiers. +Using the title slot lets the brand and native back button animate separately. + +The connection-status title has a maximum width based on its header's width and +trailing actions. A long environment name or larger text must not push Settings +into UIKit's overflow menu. Keep the full status as the accessibility label +while visually truncating it. The iPad sidebar uses its pane width, not the full +window width. + +On iOS 26, UIKit does not recognize Fabric's custom text views when shaping the +native scroll-edge fade. The screens patch gives custom title subviews an empty, +non-interactive native label matching their bounds. This supplies the fade's +geometry without drawing anything or turning the title back into a toolbar item. +Check the top scroll fade as well as push/pop when changing these title views. + +The react-native-screens patch caches leading, trailing, and center item groups +independently. A button can belong to only one group: constructing another group +with the same button removes it from the previous one. Unrelated menu updates +must therefore preserve the other groups while UIKit may be animating them. + +Each cache includes the owning header config, its item values, and custom native +item identities, so changed content or remounted event emitters still rebuild. + +Custom header identifiers require native code generation and a new mobile build; +an over-the-air JavaScript update alone is insufficient. The Android view manager +implements the generated identifier setter as a no-op because this behavior is +specific to iOS 26 and later. + +After changing a dependency patch, refresh CocoaPods before rebuilding an +existing iOS project. pnpm installs each patch hash in a different directory; +an old Pods project can keep compiling the previous directory even though Metro +and `apps/mobile/node_modules` resolve to the new patch. + +`ControlPillMenu` resolves semantic icon colors through `withUniwind` and supplies them to every +iOS `MenuView` action, including nested actions. The menu library's Fabric bridge converts a missing +`imageColor` to transparent, so callers should use this wrapper instead of +rendering `MenuView` directly. Explicit colors are preserved, and destructive +actions default to the theme's danger foreground color. Native stack header menus +use a separate implementation and do not need this workaround. + +## Native media presentations + +`PresentationSource` in `NativePresentation` registers a thumbnail for AVKit, +image zoom transitions, and UIKit's share sheet. Wrap the thumbnail as its single child +and pass the stable identifier to the presentation. The registry keeps weak +references to source views; recycled or compact composer thumbnails can register +the same identifier. Identifiers must distinguish simultaneously visible attachments. +The source registration does not own the preview. Android uses a regular view. + +On iOS, video previews mount `AVPlayerViewController` temporarily inside the +registered source and enter full screen through AVKit. AVKit +owns that zoom, its playback controls, Close button, and interactive dismissal. +Do not replace AVKit's transition with `preferredTransition`: in the iOS 27 +simulator, that leaves native Close unable to exit full screen. When the source is unavailable, +the player uses a standard modal presentation. Programmatic entry uses the same +guarded `enterFullScreenAnimated:completionHandler:` selector as Expo Video; +if that selector is unavailable, the player also falls back to a standard modal. + +`FilePreviewModal` resolves image and PDF sources from a URI, a signed environment asset, +or a retained composer file. On iOS, Quick Look owns image and document layout, controls, +zooming, sharing, and interactive dismissal. Its delegate supplies the registered thumbnail +and its bounds for Quick Look's source-view zoom. Do not layer `preferredTransition` or +another image scroll view over that presentation: Quick Look coordinates its image gestures +with the return to the thumbnail. Missing sources use the standard transition, and Reduce +Motion disables animation. A pending programmatic Close waits until the current presentation +or cancelled dismissal has settled before starting another transition. + +The shared native presenter copies original bytes into its own temporary directory and +removes that copy after dismissal. Network downloads write to disk, and sharing never edits +the source attachment. Draft images use their stored upload data rather than a potentially +expired picker URI. No React Navigation route or custom transition animator is needed. + +The same viewer handles message images, markdown images, PDF attachments and links, +composer thumbnails, and workspace image previews. The workspace PDF web preview has an +Open PDF action for the native viewer. Android retains its image viewer and uses the +system chooser for PDFs. Saving images on iOS uses the add-only photo-library permission. + +Received videos open directly from their signed asset URL. AVKit handles buffering; +the client does not download the entire file or show a separate opening overlay before +presentation. The URL is captured once per preview so credential refresh does not +restart playback. Saving or sharing still downloads the original file. + +The native presentation promise completes after dismissal. Local draft previews +hold their file lease until that promise settles. The iOS preview component requests +native dismissal when its source screen unmounts. Playback pauses in the background. +AVPlayer activates audio as playback starts. The presenter pauses and releases +its own player on close, then restores the previous audio-session configuration +if no other component changed it during playback. It does not deactivate the +shared session, which may still serve another player or recorder. Android retains +its React Native modal and Expo Video player. + +`shareFileFromSource` uses the same source registration to anchor UIKit's activity +controller. Its promise completes when the native share flow finishes, keeping +the existing attachment lease and foreground handoff active for that duration. +Android uses Expo Sharing. On iOS, received and draft video attachments expose +Save or share through `VideoAttachmentMenu`. The attachment supplies the source +identifier, and the native share presentation inherits its appearance. AVKit's +iOS playback controls do not expose a public custom-share-action API. + +Video attachment thumbnails use Expo Video's native frame extraction and Expo Image. +Received attachments use their signed asset URL; drafts retain and resolve their local file +until extraction ends. Extraction is serial; temporary players never play or change audio settings. Leaving the screen cancels +pending work; a 15-second limit prevents an unreachable source from holding up the queue. +The client keeps at most 32 native images, each bounded to 480 pixels per side, keyed by +environment and attachment identity rather than expiring URLs. Images still displayed keep +their own references when evicted from that cache. + +The asset HTTP route supports single byte ranges for videos so iOS can read metadata and +frames without first downloading the whole file. Normal downloads keep their full response; +unsupported ranges and conditional `If-Range` requests also fall back to the full file. +An older environment without range support may still show the play-card fallback. Thumbnail +failure never disables playback or sharing. diff --git a/docs/internals/model-manifest.md b/docs/internals/model-manifest.md new file mode 100644 index 000000000000..bebf5c2ee527 --- /dev/null +++ b/docs/internals/model-manifest.md @@ -0,0 +1,40 @@ +# Model manifest + +`apps/server/src/provider/model-manifest.json` is bundled for offline startup and fetched from +`main` at runtime. A remote fetch replaces the in-memory and on-disk cache only after generic +catalog references and provider-owned adapter data validate. A failed or invalid fetch keeps the +last successful remote manifest. The bundle is used only when no valid remote cache exists. + +The top-level provider catalog is generic: models contain presentation metadata, aliases, status, +an optional badge, and a reusable capability profile. The profile and model `adapter` fields are +opaque until the owning provider validates them with its own allowlisted schema. + +Claude Code uses the manifest as its complete built-in model catalog. To add a Claude model that +uses an existing profile, add one object to `providers.claudeAgent.models`. Do not add a test or +change application code. Add or change a profile in the same JSON file only when the model exposes +a capability combination that does not already exist. + +`currentModels.claudeAgent` is retained as a frozen compatibility field for releases that predate +catalog discovery. New Claude models do not need to be added there. Codex still discovers models +from its app server and uses `currentModels.codex` only as a legacy-classification overlay. + +Claude model entries support: + +- `aliases`, `status`, `badge`, and `profile` for client presentation and selection. +- `adapter.claudeCode.minVersion` and `maxVersionExclusive` for installed-runtime compatibility. +- Profile-level effort mappings, model suffixes, and context-window metadata for dispatch. + +## Test policy + +Changing model data does not require tests. Do not add or update tests for a model slug, display +name, alias, legacy status, version boundary, badge, or profile assignment. The bundled manifest is +configuration and is validated by its schema when imported. + +Add tests only when implementation behavior changes: + +- Fetching, caching, fallback, or schema-version handling changes in the manifest service. +- Provider-neutral profile resolution gains new semantics. +- A provider adapter gains a new compatibility or dispatch mapping type. + +Resolver tests must use synthetic providers and model names so normal JSON edits never create test +churn. diff --git a/docs/internals/overview.md b/docs/internals/overview.md index b9454f7b58d0..3761cbaf87b3 100644 --- a/docs/internals/overview.md +++ b/docs/internals/overview.md @@ -88,17 +88,28 @@ A turn is complete when its session leaves `running` status, projected by `settledTurnStateForSessionStatus` in [`projector.ts`][projector]. Checkpoint work settling later does not define turn end. +Thread settlement is server-owned. Per-environment settings control PR and inactivity settlement. +[`ThreadSettlementReactor`][settlement] checks threads at startup, when those settings change, and +once per minute, including when no client is connected. It dispatches the guarded internal +`thread.auto-settle` command, which uses the existing settlement event lifecycle. Automatic +settlement excludes live background work and requires a comparable PR timestamp for immediate PR +settlement. The command also rejects any later event for its thread after the reactor's snapshot. +Clients render the persisted settlement state and do not derive settlement from PR or inactivity +state. A committed `thread.settled` event also lets `ProviderCommandReactor` stop an idle provider +session. + ## Drainable workers Follow-up work runs asynchronously in queue-backed workers built on [`DrainableWorker`][worker]: [`ProviderRuntimeIngestion`][ingest] normalizes provider runtime streams into orchestration commands, -[`ProviderCommandReactor`][cmd] dispatches provider calls in response to intent events, and -[`CheckpointReactor`][checkpoint] captures and reverts workspace checkpoints. +[`ProviderCommandReactor`][cmd] dispatches provider calls in response to intent events, +[`CheckpointReactor`][checkpoint] captures and reverts workspace checkpoints, and +[`ThreadSettlementReactor`][settlement] evaluates server-owned automatic settlement rules. `DrainableWorker` pairs a transactional queue with a transactional count of outstanding items. `enqueue` atomically offers and increments; processing always decrements. `drain` retries until the count reaches zero, so a test can await "queue empty and current item finished" instead of sleeping. -Each of the three services exposes `drain` for exactly this. +Each of these four services exposes `drain` for exactly this. Runtime receipts are a test-only mechanism. `RuntimeReceiptBusLive` in [`RuntimeReceiptBus.ts`][receipts] publishes nothing; only the test layer is PubSub-backed. Do not @@ -132,8 +143,10 @@ already dispatch. ## Related - [Workspace layout](./workspace-layout.md), [Glossary](./glossary.md) +- [Mobile navigation headers](./mobile-navigation.md) - [Remote environments](./remote.md), [Server updates](./server-updates.md) - [Resource telemetry](./resource-telemetry.md) +- [Product analytics](./product-analytics.md) - [Scripts](./scripts.md), [CI gates](./ci.md) [rpc]: ../../packages/contracts/src/rpc.ts @@ -148,5 +161,6 @@ already dispatch. [ingest]: ../../apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts [cmd]: ../../apps/server/src/orchestration/Layers/ProviderCommandReactor.ts [checkpoint]: ../../apps/server/src/orchestration/Layers/CheckpointReactor.ts +[settlement]: ../../apps/server/src/orchestration/ThreadSettlementReactor.ts [receipts]: ../../apps/server/src/orchestration/Layers/RuntimeReceiptBus.ts [drivers]: ../../apps/server/src/provider/builtInDrivers.ts diff --git a/docs/internals/product-analytics.md b/docs/internals/product-analytics.md new file mode 100644 index 000000000000..3690b10baf92 --- /dev/null +++ b/docs/internals/product-analytics.md @@ -0,0 +1,112 @@ +# Product analytics + +T3 Code sends anonymous product events from the server to PostHog. The server +uses the first available hashed Codex account ID, hashed Claude user ID, or +installation-scoped anonymous ID as the distinct ID. It also keeps the +telemetry opt-out, event buffer, and batch delivery. Clients do not load the +PostHog browser SDK. + +## Client events + +These events use the metadata from the WebSocket connection that caused them. +The metadata is not a person property or server-global current-client value. +Two clients connected to one server can report different values at the same +time. + +| Event | Description | +| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | +| `client.connected` | The server accepted an authenticated WebSocket connection. Reconnects count again. Use this event for connection diagnostics, not active-use counts. | +| `client.thread.started` | The server accepted a command that created a thread. | +| `client.turn.requested` | The server accepted a turn request. This is the standard active-use event. | + +`provider.turn.sent` stays a provider execution event. It does not receive +client metadata because a provider turn can continue after the requesting +client disconnects. + +## Recommended properties + +Client properties appear on the three client events when the connected client +reports them. Older clients can omit every client property. + +| Property | Values and meaning | +| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `surface` | Product client: `web`, `desktop`, or `mobile`. | +| `webDeployment` | Web delivery: `hosted` for the hosted app or `server` for web files served by a T3 server. Web only. This does not describe connection distance. | +| `clientOs` | `macOS`, `Windows`, `Linux`, `iOS`, `Android`, `ChromeOS`, `other`, or `unknown`. | +| `clientDeviceType` | `desktop`, `phone`, `tablet`, or `unknown`. This is separate from `surface`. | +| `clientBrowser` | Normalized browser family. Web only. Browser detection is best effort. | +| `clientAppVersion` | Version of the connected client. | +| `clientOsMajorVersion` | Client OS major version when the native client reports it. Initially mobile only. | +| `clientDeviceModel` | Hardware model when the native client reports it. Initially mobile only. This is not a user-assigned device name. | +| `connectionMethod` | `direct`, `ssh`, `relay`, or `unknown`. `direct` means that the client connected to the server endpoint without an SSH or relay connection. It does not mean both processes run on one machine. | + +Server properties appear on all events, including server boot and background +events. + +| Property | Values and meaning | +| ------------------ | -------------------------------------------------------------- | +| `serverOs` | Server process OS, normalized to the same names as `clientOs`. | +| `serverArch` | Server process architecture. | +| `serverWslDistro` | WSL distribution from `WSL_DISTRO_NAME`, when present. | +| `serverAppVersion` | T3 server version. | +| `serverMode` | Server runtime mode: `desktop` or `web`. | + +## Legacy properties + +Existing property meanings do not change: + +- `clientType` describes how the server runs. It is `desktop-app` for a desktop + server and `cli-web-client` for a CLI web server. It does not describe the + connected client. Use `surface` and `webDeployment` for new reports. +- `platform`, `arch`, `wsl`, and `t3CodeVersion` describe the server. Use the + new `server*` names for new reports. +- `appVersion` describes the connected client. Use `clientAppVersion` for new + reports. +- Mobile connection events keep `os`, `osMajorVersion`, and `deviceModel`. + Use the new `client*` names for new reports. + +## PostHog dashboard + +Create one saved dashboard named `Client and platform usage`. Set +`client.turn.requested` as the event for active-use reports. A user can appear +in several client groups during one period, so do not add breakdown values to +calculate a total. + +Save these insights: + +| Insight | Configuration | +| ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Active users, daily | Trends, `client.turn.requested`, unique users, daily interval. | +| Active users, weekly | Trends, `client.turn.requested`, unique users, weekly interval. | +| Active users, monthly | Trends, `client.turn.requested`, unique users, monthly interval. | +| Client usage | Trends, `client.turn.requested`, unique users. Save four filtered series: `surface = desktop`, `surface = mobile`, `surface = web` and `webDeployment = hosted`, and `surface = web` and `webDeployment = server`. Name them Desktop, Native mobile, Hosted web, and Server-served web. | +| Client OS, active users | Trends, `client.turn.requested`, unique users, breakdown by `clientOs`. | +| Client OS, turns | Trends, `client.turn.requested`, total events, breakdown by `clientOs`. | +| Client versus server OS | Table, `client.turn.requested`, breakdown by `clientOs` and `serverOs`. | +| Connection method, active users | Trends, `client.turn.requested`, unique users, breakdown by `connectionMethod`. | +| Connection method, turns | Trends, `client.turn.requested`, total events, breakdown by `connectionMethod`. | +| Mobile devices | Table, `client.turn.requested`, filter `surface = mobile`, breakdown by `clientOs`, `clientOsMajorVersion`, and `clientDeviceType`. | +| Client version adoption | Trends, `client.turn.requested`, unique users, breakdown by `clientAppVersion`. | +| Server version adoption | Trends, `client.turn.requested`, unique users, breakdown by `serverAppVersion`. | +| Missing metadata | Table or SQL insight that shows the percentage of `client.turn.requested` events where each of `surface`, `clientOs`, `clientDeviceType`, `clientAppVersion`, and `connectionMethod` is absent. Track `webDeployment` and `clientBrowser` only within `surface = web`. | + +In PostHog Data management, use the event and property descriptions from this +document. Mark the recommended properties as verified. Keep `clientType` +visible with its legacy description so old reports remain understandable. + +## Collection and release boundary + +Client values are best effort. Invalid values are ignored and never reject a +connection. Browser clients use user-agent data for broad OS, browser, phone, +and tablet groups. They do not infer CPU architecture or an exact OS version +from `navigator.platform`. + +This change does not collect URLs, tokens, prompts, IP addresses, or +user-assigned device names. It measures authenticated product use. It does not +measure a person who visits the hosted app without connecting to a server. + +The new fields start with the first client and server release that contains +this metadata path. Historical events cannot reliably identify the client OS, +hosted web use, device type, or connection method when the old client did not +send those fields. Reports must treat missing values as pre-release or older +client data instead of backfilling them from server fields. diff --git a/docs/internals/providers.md b/docs/internals/providers.md index a309d70f03de..75163f0cc0f4 100644 --- a/docs/internals/providers.md +++ b/docs/internals/providers.md @@ -39,6 +39,90 @@ directory to route session and turn operations for a thread, so callers name a t Adding a driver means writing the driver plus adapter and adding it to `BUILT_IN_DRIVERS`. No orchestration, contract, or client change is required for the common case. +## OpenCode server ownership and catalog + +Each OpenCode provider instance owns one lazy local server for catalog discovery and +text-generation helpers through [`OpenCodeServerOwner.ts`][opencode-server-owner]. Concurrent +borrowers share startup. The server closes 30 seconds after the last borrower releases it, or +when the provider instance closes. A failed or exited process can be started again on the next +use. An externally configured OpenCode server remains externally owned. + +The local server and its SDK clients use one resolved password. An explicit provider password +overrides `OPENCODE_SERVER_PASSWORD` in the spawned environment. Without an explicit password, +the client uses the password from the environment that the process inherits. External servers use +only their explicit provider password and never inherit the host's local password. + +Every server connection must pass the authenticated `/global/health` check before inventory or +session operations start. The response must contain a valid version at or above 1.14.19. Local +owners cache this result for the lifetime of the spawned process. External actions check once when +they create their server connection, not for each model or SDK request. + +Chat adapters keep their own server per thread. They register a thread-specific `t3-code` MCP +connection, while OpenCode stores MCP connections by directory. Sharing these chat servers +without changing MCP routing would let two threads in one directory replace each other's +connection. + +OpenCode loads its catalog through the HTTP API when an enabled provider instance starts. The +provider registry keeps the snapshot in memory and persists it in the existing per-instance cache. +Each `subscribeServerConfig` connection refreshes all providers, so a client reconnect reloads the +OpenCode catalog from the current helper. The `serverRefreshProviders` request also refreshes it. +Periodic OpenCode probes remain disabled. OpenCode reads credentials for each inventory request, +but its native configuration files can remain cached for the lifetime of the helper process. The +helper closes 30 seconds after its last inventory or text-generation borrower releases it. A +refresh after that idle period starts a new helper and reads file changes. Repeated refreshes and +active text-generation work can extend process reuse. Changes to the provider configuration or +environment replace the instance and start a new discovery. Changes to unrelated settings only +update snapshot enrichment. Other providers retain their existing refresh policy. + +T3 Code does not own an external OpenCode process. Native configuration changes there can require +an external reload or restart before T3 Code's next refresh sees them. + +The shared server's idle shutdown does not clear the catalog. Failed discovery keeps the last +known models, slash commands, and skills through the registry's existing merge rules. A successful +empty inventory is authoritative. Existing threads keep their explicit model identifier and +options when catalog metadata is missing; the catalog is not permission to choose a different +model for a thread. + +## Model manifest + +The model picker's legacy section is driven by `apps/server/src/provider/model-manifest.json`, which +lists the current (non-legacy) model slugs per driver kind. The `ModelManifest` service +(`apps/server/src/provider/ModelManifest.ts`) refreshes that data from the same file on `main` via +raw.githubusercontent.com, so moving a model in or out of the legacy section is a commit, not a +release. Preference order is remote fetch, then the on-disk copy of the last successful fetch (in +the state directory), then the bundled copy. Fetches are TTL-gated, run concurrently with provider +probes, respect the `enableProviderUpdateChecks` setting, and never fail a provider check. The +Codex and Claude drivers apply the classification to every snapshot with `applyModelManifest`; +driver kinds absent from the manifest have no legacy concept. + +## Attachment access + +The server stores uploaded attachments in its attachment directory, outside the project workspace. +`ProviderService` adds the absolute path of each attachment to the turn text, then passes every +attachment to the provider adapter. Each adapter decides what its provider ingests natively: + +- Codex, Claude, Cursor, and Grok send images as native image inputs and skip generic files. For + these providers, generic files reach the agent only as file paths in the turn text. +- OpenCode sends PNG/JPEG/GIF/WebP images, text files, and PDFs up to 20 MB as native file parts + with their real mime type. Everything else (ZIP and other binaries, image formats model APIs + reject, oversized files) falls back to the file path in the turn text, like the other providers. + +Claude receives the attachment directory as an allowed additional directory. Codex keeps its +configured sandbox policy, so access depends on that policy and the selected runtime mode. OpenCode +allows all paths in full-access mode and requests approval for directories outside the workspace in +restricted modes. Cursor and Grok use their own provider permission rules. + +The server does not copy attachments into a project or bypass provider approval rules. If an agent +cannot read an attachment, the user must approve the access or select a runtime mode that permits it. + +Updated attachment schemas tolerate unknown attachment members, but old image-only clients still +cannot decode messages that contain file attachments. Client file-picking rollouts must account for +this limit. + +Do not run an old image-only server against state that contains file attachments. Replay decodes +each persisted event before projection. A file-bearing event can make `ProjectionPipeline` bootstrap +and `OrchestrationEngine` startup fail for the entire environment, not only the affected thread. + ## How provider work is requested Clients never call a provider directly. They dispatch orchestration commands over the RPC method @@ -81,6 +165,7 @@ when a request opens (approval) or user input is requested, via [cursor]: ../../apps/server/src/provider/Drivers/CursorDriver.ts [grok]: ../../apps/server/src/provider/Drivers/GrokDriver.ts [opencode]: ../../apps/server/src/provider/Drivers/OpenCodeDriver.ts +[opencode-server-owner]: ../../apps/server/src/provider/OpenCodeServerOwner.ts [adapter]: ../../apps/server/src/provider/Services/ProviderAdapter.ts [instances]: ../../apps/server/src/provider/Services/ProviderInstanceRegistry.ts [registry]: ../../apps/server/src/provider/Services/ProviderAdapterRegistry.ts diff --git a/docs/internals/remote.md b/docs/internals/remote.md index afce95f725bc..65416a19e967 100644 --- a/docs/internals/remote.md +++ b/docs/internals/remote.md @@ -41,6 +41,10 @@ It is identified by a stable `environmentId`, persisted by the server at `/environment-id.recovery` file so concurrent and delayed repairs choose +the same ID. Existing nonempty ID files remain authoritative. + ### Known environments and connection targets A saved client-side entry for an environment the client knows how to reach. It is not diff --git a/docs/internals/resource-telemetry.md b/docs/internals/resource-telemetry.md index 0d07f31f8ac5..0f3e6674ff79 100644 --- a/docs/internals/resource-telemetry.md +++ b/docs/internals/resource-telemetry.md @@ -103,9 +103,9 @@ power-adaptive interval selected by the server. It collects: - resident and virtual memory; - cumulative process I/O counters. -On Linux, task/thread enumeration is disabled. Command lines are loaded only -when first needed. This avoids the expensive default behavior of walking every -`/proc//task/` directory on each refresh. +On Linux, task/thread enumeration is disabled. Command lines are refreshed with +each sample so process replacements remain visible. Disabling task enumeration +avoids walking every `/proc//task/` directory on each refresh. ### Process-tree selection @@ -142,7 +142,8 @@ The server adjusts native sampling without restarting the sidecar: - suspended, locked, low-power, or serious/critical thermal state: 15 seconds; - battery: 5 seconds; -- normal AC: 1 second; +- normal AC: 5 seconds in the background and 1 second while live diagnostics is + open; - unknown or stale power: 5 seconds in the background and 1 second while live diagnostics is open. diff --git a/docs/internals/voice-input.md b/docs/internals/voice-input.md new file mode 100644 index 000000000000..dd37f2a86957 --- /dev/null +++ b/docs/internals/voice-input.md @@ -0,0 +1,102 @@ +# Voice input + +> For maintainers. Using T3 Code? See [voice input on iPhone](../user/composer.md#voice-input-on-iphone). + +Voice input produces editable composer text. The current implementation records on the client and +transcribes locally with Apple's `SpeechAnalyzer` and `SpeechTranscriber` on supported iOS 26+ +devices. Environment-provided transcription and transcription on web and desktop are not implemented. + +## Current boundaries + +The shared [`VoiceInputController`][controller] in `packages/client-runtime` owns preparation, +recording, transcription, cancellation, temporary-file cleanup, and insertion into the captured +draft selection. Applications import it through the [voice-input entry point][voice-input] as +`@t3tools/client-runtime/voice-input`. Its dependencies separate capture from transcription; the +controller imports neither React Native nor an Apple transcription API. + +The shared [transcription contract][transcription] defines `VoiceTranscriber`, +`PreparedVoiceTranscription`, and transcription errors. The controller calls `getTranscriber()` once +at the start of an operation, before asking for microphone permission. Preparation returns a resolved +locale and a bound `transcribe` function. The controller retains that result for the recording, so a +selection change cannot prepare with one implementation and transcribe with another. + +[`useVoiceInputController`][hook] supplies Expo audio capture, microphone permissions, audio-session +management, waveform samples, and app and navigation lifecycle handling. It normalizes Expo's +`mediaServicesDidReset` into a generic recorder error. [`voiceTranscription.ios.ts`][ios] adapts +`@react-native-ai/apple` through `getLocalVoiceTranscriber()`, capturing the requested device locale +and binding the prepared transcriber to Apple's resolved locale. The other-platform binding returns +no local transcriber. That result describes the local implementation, not whether a client could use +an environment's transcription service. + +Mobile's [`voiceInputPresentation.ts`][presentation] maps shared state to toolbar labels and actions. +Waveform and toolbar rendering stay in mobile. The composer edits draft text without selecting a +speech vendor. +Recording captures the draft owner, revision, text, and selection. A late transcript cannot overwrite +a different or edited draft. Only normal message submission sends the resulting text to an agent. + +Each operation passes one `AbortSignal` through preparation and transcription. Cancellation +invalidates the operation and aborts that signal immediately. Implementations settle their promises +only after their underlying work stops. The Apple binding checks cancellation between asynchronous +steps but cannot interrupt an in-flight native request. The controller retains its session until +that work settles, ignores its result, and cleans up the recording. + +## Ownership decisions + +The extension boundary distinguishes transcription on the client device from transcription through +the composer's environment. These constraints apply when adding selectable transcription services: + +- Local means the client device, regardless of which machine hosts the environment. A device's lack + of local recognition does not prevent it from recording audio for an environment service. +- Remote service configuration and API keys belong to the environment. The environment calls the + external service. Clients receive service identifiers, labels, and availability information, never + credential values. Transcription services are independent of coding-agent `providerInstances`; + selecting OpenAI for transcription does not select Codex for the thread. +- The client owns its transcription preference, scoped by stable `environmentId`. Its choices are + supported local recognition and the services exposed by the composer's environment. A service ID + is meaningful only within that environment. Different clients can make different choices. +- Resolve and capture the environment, selected service, and locale when an operation starts. + Preparation and transcription use the same selection; preference changes affect the next + recording. Capture environment identity explicitly rather than recovering it from a draft key. + Keep the existing draft-owner and revision checks before inserting text. +- If the selected option is unavailable, report that state and let the user choose another option. + A local failure must not silently upload audio, and a disconnected environment must not redirect + a recording to another environment or service. +- Transcription audio is temporary input, separate from durable chat attachments and messages. + Remote adapters need cancellation of upload and transcription where supported, cleanup after + success, failure, or cancellation, and the same protection against late results as local transcription. + +## Existing integration points + +[`ServerSettingsService`][settings] and [`ServerSecretStore`][secrets] provide environment-owned +configuration and secret persistence. Existing settings redaction handles coding-provider environment +variables only. Any transcription credential fields need their own explicit separation and redaction +before settings responses or subscriptions reach client caches. + +[`ExecutionEnvironmentCapabilities`][capabilities] handles version skew. Remote transcription must be +opt-in: a missing transcription capability means unsupported. The authenticated server-config +subscription and [shared environment state][server-state] already distribute configuration per +environment. A transcription service catalog belongs behind that capability and authenticated +boundary. Older servers expose no remote transcription choices. + +The [attachment upload contracts][uploads] and [shared upload operations][attachment-state] provide a +pattern for authorized binary uploads through an environment, including remote connections. Their +existing chat-attachment retention is not a transcription cleanup policy. + +Future service selection and environment requests belong alongside the controller in +`packages/client-runtime`, with wire contracts in `packages/contracts`. Capture and native local +recognition remain client-specific. An environment-backed transcriber implements the same shared +contract, with its environment and service bound when selected. The controller does not own service +credentials, provider SDKs, or transport selection. + +[controller]: ../../packages/client-runtime/src/voice-input/controller.ts +[voice-input]: ../../packages/client-runtime/src/voice-input/index.ts +[transcription]: ../../packages/client-runtime/src/voice-input/transcription.ts +[hook]: ../../apps/mobile/src/features/voice-input/useVoiceInputController.ts +[presentation]: ../../apps/mobile/src/features/voice-input/voiceInputPresentation.ts +[ios]: ../../apps/mobile/src/native/voiceTranscription.ios.ts +[settings]: ../../apps/server/src/serverSettings.ts +[secrets]: ../../apps/server/src/auth/ServerSecretStore.ts +[capabilities]: ../../packages/contracts/src/environment.ts +[server-state]: ../../packages/client-runtime/src/state/server.ts +[uploads]: ../../packages/contracts/src/assets.ts +[attachment-state]: ../../packages/client-runtime/src/state/attachments.ts diff --git a/docs/operations/observability.md b/docs/operations/observability.md index 7341bfb5edac..966eab112c6b 100644 --- a/docs/operations/observability.md +++ b/docs/operations/observability.md @@ -49,6 +49,12 @@ records instead carry OTLP resource, scope, and optional status fields. The `TraceRecord`, `EffectTraceRecord`, and `OtlpTraceRecord` schemas live in `packages/shared/src/observability.ts`. +DPoP proof failures include the safe `environment.dpop.failure_code` span +attribute. A `time_window` failure means that a signed proof was too old or too +far in the future for the environment server's allowed window. It can point to +a date or time problem on either device, but it can also result from a delayed +request. + ### Metrics Metrics are not written to a local file. diff --git a/docs/operations/relay-observability.md b/docs/operations/relay-observability.md index 2bc697b2ef1f..c7f84d4ef825 100644 --- a/docs/operations/relay-observability.md +++ b/docs/operations/relay-observability.md @@ -51,3 +51,9 @@ Agents should prefer the provisioned view or APL queries for completed incidents tailing the Cloudflare Worker. The stack does not provision a separate query token. Responders who need scripted query access use the authorized account-level `AXIOM_TOKEN` together with `AXIOM_ORG_ID`; scoped ingest tokens remain write-only credentials for their producers. + +DPoP proof failures include the stable `relay.dpop.failure_code` span attribute. A `time_window` +failure means that a signed proof was too old or too far in the future for the relay's allowed +window. It can point to a date or time problem on either device, but it can also result from a +delayed request. The client uses this category, and the absence of a category from an older relay, +to decide whether clock skew is confirmed or only one possible cause. diff --git a/docs/operations/release.md b/docs/operations/release.md index 520af51d9ea7..e5a4d3544c35 100644 --- a/docs/operations/release.md +++ b/docs/operations/release.md @@ -188,10 +188,12 @@ the **Update server** action targeting a package version that does not exist yet For a release smoke test, confirm `npm view t3@ version` returns the expected version, then connect the new client to a server on the previous version and verify that the update action -reconnects to the matching server. Use releases with identical migration manifests for the -automatic path. When the manifest changed, verify that the remote action stops before restart and -shows the exact local `npx t3@ service update` command. Also test the manual or -desktop-managed guidance when those environments are available. +reconnects to the matching server. When the release adds database migrations, verify that the +remote update applies them and reconnects. A failed trial must restore the database snapshot and +restart the previous server. If the installed launcher does not support the target protocol, +verify that the update stops before restart and run `npx t3@ service update` once on the +server machine. Also test the manual or desktop-managed guidance when those environments are +available. ## Desktop auto-update notes @@ -220,10 +222,12 @@ Windows packages the bundled server and only its runtime-external/native dependency closure in `resources/server.asar`. Native modules and helper executables declared as unpacked by that archive must be present at the matching paths below `resources/server.asar.unpacked`. The Windows-native backend reads -the archive in place through Electron. WSL cannot read ASAR files, so enabling -the WSL backend extracts the server tree once into the desktop state directory -under `wsl-server-tree/` and reuses the completed version until the app -is updated. +the archive in place through Electron. Packaged Windows builds also ship a +Linux-only `resources/wsl-runtime.tar.gz` plus its SHA-256 sidecar. WSL verifies +and extracts that archive into `~/.t3/wsl-runtime/sha256-` inside +the selected distro, then reuses it for later launches of the same update. The +Windows-side `wsl-server-tree/` extraction remains a fallback and is +removed after the distro-local runtime passes preflight. The artifact builder rejects a Windows package when any of these invariants break: @@ -234,6 +238,11 @@ break: - On same-architecture Windows builds, the packaged primary cannot load the fff native library from inside `server.asar` through its `.unpacked` sibling. - The isolated, extracted sidecar cannot load the server entry with plain Node. +- A Windows build with a WSL node-pty prebuild omits the WSL archive or SHA-256 + sidecar, the sidecar digest does not match the emitted archive, or required + Linux runtime members are absent. +- The emitted WSL archive contains Windows/Darwin node-pty payloads, ConPTY, + pnpm install metadata, or Windows-only FFF, ffi-rs, or msgpackr bindings. - The external Windows resource monitor is absent. - The unpacked Windows application contains more than 80 files. diff --git a/docs/user/composer.md b/docs/user/composer.md index b7ef57a56015..5ac274381400 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -4,20 +4,161 @@ Messages can contain up to 120,000 characters. If a draft is longer, T3 Code kee composer and shows how many characters need to be removed. Shorten the draft or split it into multiple messages, then send again in the same thread. -On servers that support direct uploads, images upload as soon as you add them. The send button -becomes available after every upload finishes. Failed uploads can be retried or removed. +On mobile, an empty composer shows an interrupt button while the agent is working. Adding text +or an attachment replaces it with the send button. This applies to both compact and expanded +composers. + +You can attach images up to 10 MB. On servers that support file uploads, you can also +attach videos, text files, PDFs, ZIP archives, and other files. Each file can be up to the limit advertised +by the server, capped at 50 MB. Each message can contain up to eight attachments in total. Files +upload directly to the environment, where your agent can read, copy, or edit them by their file path. + +Attachments upload as soon as you add them while connected to a server that supports uploads. +The send button becomes available after every upload finishes. Failed uploads can be retried or +removed. On mobile, tap **+** to open +the photo library from either the compact or expanded composer. When the connected server supports +file uploads, **+** opens a menu beside the button with **Photo Library** and **Choose Files**. +Videos use the server's file upload limit. You can also share photos, videos, and files into +T3 Code from other apps through the system share sheet. Mobile keeps a local copy of each draft +attachment, so you can still preview it and queue messages while offline. Uploads resume when +you reconnect. Drafts and queued messages survive app restarts; signing out of T3 Connect keeps +them on your device until you sign back into the same account. Select a received file on mobile +to preview it or open the system share options. + +Tap an image or PDF before or after sending to open it. On iOS, images zoom from their thumbnail +into the native viewer. Pinch or double-tap to zoom, and swipe down or tap Close to return. +Use Share to save a copy or send it to another app. PDFs support page navigation and search. +PDF links in assistant responses open the same preview. On Android, images open in the image +viewer and PDFs open the system chooser. + +Select a video attachment before or after sending to play it. Web and desktop use the browser's +built-in controls. On mobile, videos open in a full-screen player with native playback controls. +Supported videos show a thumbnail in the conversation and composer. +On web, desktop, and iOS, received videos stream from their environment as they play. Supported formats and codecs +depend on the browser or device; you can save an unsupported video to open it in another app. + +On iOS, the system player zooms from the attachment. Swipe down or tap Close to return to the +conversation or draft. Touch and hold the attachment, then choose **Save or share video** to open +the system share options. On Android, use **Save or share video** inside the preview. + +On web and desktop, if you reload before a file finishes uploading, the draft keeps the file's name +and shows **Attach again** next to it. Attach the file again or remove it, then send. + +On web and desktop, HEIC and HEIF photos are automatically converted to JPEG when you drag them into +the composer or paste them into a message. On iOS, selecting them from **Photo Library** also +converts them to JPEG. The 10 MB image limit applies to the converted photo. + +On mobile, the model picker shows each OpenCode model's upstream provider, such as Anthropic, +GitHub Copilot, or OpenCode Zen, beneath its name. Search by that provider name to narrow the list +when starting a thread or changing an existing thread's model. + +## Images and videos in messages + +On web, desktop, and mobile, select a link to an image or video to open it inside T3 Code. +Workspace image and video links open the file viewer. Links to media outside the workspace +open a media preview. +Videos opened from the file explorer or a file-viewer tab also play inside T3 Code. They +stream from the environment as needed, rather than downloading the entire video before playback. +Paths in inline code, such as `/tmp/recording.mp4`, work the same way. Image embeds stay inline; +video embeds show a player with controls and an option to expand. Visible video previews load +an initial frame when supported, but stay paused until you press Play. Video file references use +a filmstrip icon. + +On web and desktop, hover over a preview to see its full file path or original URL. Right-click +to copy that reference, save an image, or copy an image to the clipboard. Use the video player's +built-in controls to download videos. If the player cannot decode a video, its error message +offers a link to open the source in the browser. Workspace media also offers **Copy relative +path** and **Open in file viewer**. These actions are available in expanded previews too. + +On mobile, touch and hold an inline image or use a preview's **Media actions** menu to see its +source, copy the path or URL, or choose **Save or share**. Workspace media can open in the file +viewer from the same menu. Saving downloads a copy only when you request it; it does not change +how the video buffers during playback. + +Use Markdown image syntax to embed either kind of media: + +```markdown +![Screenshot](/tmp/screenshot.png) +![Recording](/tmp/recording.mp4) +[Open recording](/tmp/recording.mp4) +``` + +Relative paths resolve from the thread's workspace. Absolute paths and `file://` links refer to +the environment's machine, even when you connect remotely or use your phone. Supported media +can live outside the workspace, including in Downloads or `/tmp`. + +T3 Code serves the original file without adding it to attachment storage. If that file is moved +or deleted, its preview can no longer load from the environment. A browser or device may still +have a cached copy. Supported video formats and codecs depend on the browser or device. + +Bare paths in ordinary prose and paths inside code blocks stay text. Raw HTML `
    ;`, + ); + + guardedMobileFile.invalid( + "reports new React theme subscriptions", + ` + import { useCSSVariable } from "uniwind"; + + export const foreground = useCSSVariable("--color-foreground"); + `, + (output) => { + assert.match(output, /semantic className/); + }, + ); + + guardedMobileFile.invalid( + "reports the retired theme color hook", + ` + import { useThemeColor } from "../../../hooks/useThemeColor"; + + export const foreground = useThemeColor({}, "foreground"); + `, + (output) => { + assert.match(output, /replaced by semantic Uniwind classes/); + }, + ); + + guardedMobileFile.invalid( + "reports guarded hooks imported with TypeScript extensions", + ` + import { useThemeColor } from "../../../hooks/useThemeColor.ts"; + import { useUniwindTheme } from "../../../lib/useUniwindTheme.ts"; + + export const foreground = [ + useThemeColor({}, "foreground"), + useUniwindTheme().colors.foreground, + ]; + `, + (output) => { + assert.match(output, /replaced by semantic Uniwind classes/); + assert.match(output, /native\/third-party interop boundary/); + }, + ); + + guardedMobileFile.invalid( + "reports unreviewed native interop subscriptions", + ` + import { useUniwindTheme } from "../../../lib/useUniwindTheme"; + + export const foreground = useUniwindTheme().colors.foreground; + `, + (output) => { + assert.match(output, /native\/third-party interop boundary/); + }, + ); + + guardedMobileFile.invalid( + "reports appearance variants in string literals", + `const surface = ;`, + (output) => { + assert.match(output, /registered custom themes/); + }, + ); + + guardedMobileFile.invalid( + "reports appearance variants in template literals", + "const className = `bg-black light:bg-white`;", + ); + + guardedMobileFile.invalid( + "reports escaped appearance variants in template literals", + "const className = `dark\\u003abg-black`;", + ); + + guardedMobileFile.invalid( + "reports appearance variants through nested class-map indirection", + ` + const styles = { + variants: { root: "bg-white dark:bg-black" }, + }; + + const root = styles.variants.root; + export const surface = ; + `, + (output) => { + expect(output.match(/registered custom themes/g)).toHaveLength(1); + }, + ); + + guardedMobileFile.invalid( + "reports appearance variants passed to class builders", + `const surface = cn("bg-white", enabled && "dark:bg-black");`, + ); + + guardedMobileFile.invalid( + "reports negative and important appearance variants", + `const className = "dark:-mt-2 light:!bg-white";`, + ); + + guardedMobileFile.invalid( + "reports namespace CSS variable subscriptions", + ` + import * as Uniwind from "uniwind"; + + export const foreground = Uniwind.useCSSVariable("--color-foreground"); + `, + ); + + guardedMobileFile.invalid( + "reports destructured namespace CSS variable subscriptions", + ` + import * as Uniwind from "uniwind"; + + const { useCSSVariable: resolveVariable } = Uniwind; + export const foreground = resolveVariable("--color-foreground"); + `, + ); + + guardedMobileFile.invalid( + "reports aliased namespace CSS variable subscriptions", + ` + import * as Uniwind from "uniwind"; + + const Theme = Uniwind; + const NestedTheme = Theme; + export const foreground = NestedTheme.useCSSVariable("--color-foreground"); + `, + ); + + guardedMobileFile.invalid( + "reports object-rest namespace CSS variable subscriptions", + ` + import * as Uniwind from "uniwind"; + + const { ...Theme } = Uniwind; + export const foreground = Theme.useCSSVariable("--color-foreground"); + `, + ); + + guardedMobileFile.invalid( + "reports namespace access to the retired theme hook", + ` + import * as ThemeColor from "../../../hooks/useThemeColor"; + + export const foreground = ThemeColor.useThemeColor({}, "foreground"); + `, + ); + + guardedMobileFile.invalid( + "reports destructured namespace access to the retired theme hook", + ` + import * as ThemeColor from "../../../hooks/useThemeColor.ts"; + + const { useThemeColor: resolveThemeColor } = ThemeColor; + export const foreground = resolveThemeColor({}, "foreground"); + `, + ); +}); diff --git a/oxlint-plugin-t3code/rules/no-mobile-uniwind-theme-escape-hatches.ts b/oxlint-plugin-t3code/rules/no-mobile-uniwind-theme-escape-hatches.ts new file mode 100644 index 000000000000..5e6a7111e816 --- /dev/null +++ b/oxlint-plugin-t3code/rules/no-mobile-uniwind-theme-escape-hatches.ts @@ -0,0 +1,225 @@ +import { defineRule, type Variable } from "@oxlint/plugins"; +import * as Option from "effect/Option"; + +import { getPropertyName, unwrapExpression } from "../utils.ts"; + +const MOBILE_SOURCE_MARKER = "/apps/mobile/src/"; +const APPEARANCE_VARIANT_PATTERN = /\b(?:dark|light):(?=\S)/u; +const APPEARANCE_VARIANT_MESSAGE = + "dark:/light: utilities do not follow registered custom themes; use an adaptive semantic token."; +const THEME_INTEROP_ALLOWLIST = new Set([ + "features/archive/ArchivedThreadsScreen.tsx", + "features/connection/ConnectionsNewRouteScreen.tsx", + "features/files/FileMarkdownPreview.tsx", + "features/files/SourceFileSurface.tsx", + "features/files/ThreadFilesRouteScreen.tsx", + "features/files/thread-file-navigator-pane.tsx", + "features/home/HomeHeader.tsx", + "features/review/ReviewSheet.tsx", + "features/review/useNativeReviewDiffBridge.ts", + "features/settings/SettingsEnvironmentsRouteScreen.tsx", + "features/settings/appearance/components/AppearancePreviews.tsx", + "features/settings/appearance/components/FontSizeSliderRow.tsx", + "features/threads/GitActionProgressOverlay.tsx", + "features/threads/NewTaskContextPickerScreens.tsx", + "features/threads/NewTaskDraftScreen.tsx", + "features/threads/ThreadComposer.tsx", + "features/threads/ThreadFeed.tsx", + "features/threads/ThreadSettingsSheet.tsx", + "features/threads/git/GitOverviewSheet.tsx", + "features/threads/thread-list-items.tsx", + "features/threads/thread-list-v2-items.tsx", + "lib/useMobileNavigationTheme.ts", + "native/T3ComposerEditor.ios.tsx", + "native/T3ComposerEditor.native.tsx", +]); + +const mobileSourcePath = (filename: string): string | undefined => { + const normalized = `/${filename.replaceAll("\\", "/")}`; + const markerIndex = normalized.lastIndexOf(MOBILE_SOURCE_MARKER); + return markerIndex === -1 + ? undefined + : normalized.slice(markerIndex + MOBILE_SOURCE_MARKER.length); +}; + +const literalStringValue = (node: unknown): Option.Option => { + if (typeof node !== "object" || node === null) return Option.none(); + if (!("type" in node) || node.type !== "Literal") return Option.none(); + if (!("value" in node) || typeof node.value !== "string") return Option.none(); + return Option.some(node.value); +}; + +const reportsAppearanceVariant = (value: string) => APPEARANCE_VARIANT_PATTERN.test(value); + +const importsModule = (source: string, modulePath: string): boolean => + source.replace(/\.[cm]?[jt]sx?$/u, "").endsWith(modulePath); + +export default defineRule({ + meta: { + type: "problem", + docs: { + description: + "Keep mobile theme styling on semantic Uniwind classes and reviewed native interop boundaries.", + }, + }, + create(context) { + const sourcePath = mobileSourcePath(context.filename); + if (sourcePath === undefined) return {}; + + const uniwindNamespaces = new Set(); + + const resolveVariable = (node: unknown): Variable | undefined => { + const identifier = unwrapExpression(node); + if (Option.isNone(identifier) || identifier.value.type !== "Identifier") return undefined; + + let scope = context.sourceCode.getScope(identifier.value); + while (true) { + const variable = scope.set.get(identifier.value.name); + if (variable !== undefined || scope.upper === null) return variable; + scope = scope.upper; + } + }; + + return { + ImportDeclaration(node) { + const source = literalStringValue(node.source); + if (Option.isNone(source)) return; + const declaredVariables = context.sourceCode.getDeclaredVariables(node); + + for (const specifier of node.specifiers) { + const local = unwrapExpression(specifier.local); + const importedName = + specifier.type === "ImportSpecifier" + ? getPropertyName(specifier.imported) + : Option.none(); + const isTypeOnly = + node.importKind === "type" || + (specifier.type === "ImportSpecifier" && specifier.importKind === "type"); + + if ( + !isTypeOnly && + specifier.type === "ImportNamespaceSpecifier" && + Option.isSome(local) && + local.value.type === "Identifier" + ) { + const localName = local.value.name; + const variable = declaredVariables.find((candidate) => candidate.name === localName); + if (source.value === "uniwind" && variable !== undefined) { + uniwindNamespaces.add(variable); + } + } + + if ( + !isTypeOnly && + source.value === "uniwind" && + Option.isSome(importedName) && + importedName.value === "useCSSVariable" + ) { + context.report({ + node: specifier, + message: + "Use a semantic className instead of useCSSVariable; it adds a React theme subscription.", + }); + } + + if (!isTypeOnly && importsModule(source.value, "/useThemeColor")) { + context.report({ + node: specifier, + message: "useThemeColor was replaced by semantic Uniwind classes.", + }); + } + + if ( + !isTypeOnly && + importsModule(source.value, "/useUniwindTheme") && + !THEME_INTEROP_ALLOWLIST.has(sourcePath) + ) { + context.report({ + node: specifier, + message: + "Use className for theme styling, or review and add this native/third-party interop boundary to the lint allowlist.", + }); + } + } + }, + MemberExpression(node) { + const object = unwrapExpression(node.object); + if (Option.isNone(object) || object.value.type !== "Identifier") return; + + const property = getPropertyName(node.property); + if (Option.isNone(property)) return; + + const namespace = resolveVariable(object.value); + if ( + namespace !== undefined && + uniwindNamespaces.has(namespace) && + property.value === "useCSSVariable" + ) { + context.report({ + node, + message: + "Use a semantic className instead of useCSSVariable; it adds a React theme subscription.", + }); + } + }, + VariableDeclarator(node) { + const initializer = unwrapExpression(node.init); + const binding = unwrapExpression(node.id); + if ( + Option.isNone(initializer) || + initializer.value.type !== "Identifier" || + Option.isNone(binding) + ) { + return; + } + + const namespace = resolveVariable(initializer.value); + if (namespace === undefined || !uniwindNamespaces.has(namespace)) return; + + if (binding.value.type === "Identifier") { + const bindingName = binding.value.name; + const variable = context.sourceCode + .getDeclaredVariables(node) + .find((candidate) => candidate.name === bindingName); + if (variable !== undefined) uniwindNamespaces.add(variable); + return; + } + + if (binding.value.type !== "ObjectPattern") return; + + const declaredVariables = context.sourceCode.getDeclaredVariables(node); + for (const propertyNode of binding.value.properties) { + if (propertyNode.type === "RestElement") { + const restBinding = unwrapExpression(propertyNode.argument); + if (Option.isNone(restBinding) || restBinding.value.type !== "Identifier") continue; + + const restName = restBinding.value.name; + const variable = declaredVariables.find((candidate) => candidate.name === restName); + if (variable !== undefined) uniwindNamespaces.add(variable); + continue; + } + + if (propertyNode.type !== "Property") continue; + const property = getPropertyName(propertyNode.key); + if (Option.isNone(property)) continue; + + if (property.value === "useCSSVariable") { + context.report({ + node: propertyNode, + message: + "Use a semantic className instead of useCSSVariable; it adds a React theme subscription.", + }); + } + } + }, + Literal(node) { + if (typeof node.value !== "string" || !reportsAppearanceVariant(node.value)) return; + context.report({ node, message: APPEARANCE_VARIANT_MESSAGE }); + }, + TemplateElement(node) { + if (!reportsAppearanceVariant(node.value.cooked ?? "")) return; + context.report({ node, message: APPEARANCE_VARIANT_MESSAGE }); + }, + }; + }, +}); diff --git a/oxlint-plugin-t3code/test/utils.ts b/oxlint-plugin-t3code/test/utils.ts index b1d7fccf38e3..704c3436ce13 100644 --- a/oxlint-plugin-t3code/test/utils.ts +++ b/oxlint-plugin-t3code/test/utils.ts @@ -114,6 +114,7 @@ export const createOxlintRuleHarness = ( rules: { [ruleName]: "error" }, }), ); + yield* fs.makeDirectory(path.dirname(sourcePath), { recursive: true }); yield* fs.writeFileString(sourcePath, source); // Run through the current Node binary: oxlint's bin is an extensionless diff --git a/package.json b/package.json index 3fc66d0dd021..46913a435a8a 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "screenshots:mobile": "node scripts/mobile-showcase.ts", "icons:export": "node scripts/export-brand-icons.ts", "icons:check": "node scripts/export-brand-icons.ts --check", - "build": "vp run --filter './apps/*' --filter './packages/*' --filter './oxlint-plugin-t3code' --filter './scripts' build", + "build": "vp run --filter './apps/*' build", "build:marketing": "vp run --filter @t3tools/marketing build", "build:desktop": "vp run --filter @t3tools/desktop --filter t3 build", "build:resource-monitor": "cargo build --locked --release --manifest-path native/resource-monitor/Cargo.toml", @@ -31,7 +31,6 @@ "test:desktop-smoke": "vp run --filter @t3tools/desktop smoke-test", "fmt": "vp fmt", "fmt:check": "vp fmt --check", - "build:contracts": "vp run --filter @t3tools/contracts build", "dist:desktop:artifact": "node scripts/build-desktop-artifact.ts", "dist:desktop:dmg": "node scripts/build-desktop-artifact.ts --platform mac --target dmg", "dist:desktop:dmg:arm64": "node scripts/build-desktop-artifact.ts --platform mac --target dmg --arch arm64", @@ -41,7 +40,6 @@ "dist:desktop:win:arm64": "node scripts/build-desktop-artifact.ts --platform win --target nsis --arch arm64", "dist:desktop:win:x64": "node scripts/build-desktop-artifact.ts --platform win --target nsis --arch x64", "release:smoke": "node scripts/release-smoke.ts", - "connect:announce-ga": "node scripts/announce-connect-ga.ts", "clean": "rm -rf node_modules apps/*/node_modules packages/*/node_modules apps/*/dist apps/*/dist-electron packages/*/dist .vite-plus apps/*/.vite-plus packages/*/.vite-plus", "sync:repos": "node scripts/sync-reference-repos.ts" }, @@ -56,10 +54,5 @@ "engines": { "node": "^24.13.1" }, - "packageManager": "pnpm@11.10.0", - "msw": { - "workerDirectory": [ - "apps/web/public" - ] - } + "packageManager": "pnpm@11.10.0" } diff --git a/packages/client-runtime/README.md b/packages/client-runtime/README.md index 722d6f6d389d..f2864a2db588 100644 --- a/packages/client-runtime/README.md +++ b/packages/client-runtime/README.md @@ -5,18 +5,19 @@ subpath. The package intentionally has no root export. ## Public subpaths -| Subpath | Responsibility | -| --------------------- | ---------------------------------------------------------------- | -| `authorization` | Bearer and DPoP authorization plus token persistence contracts | -| `connection` | Targets, catalog, supervision, retries, registry, and onboarding | -| `environment` | Environment identity, descriptors, endpoints, and scoped keys | -| `errors` | Shared client error inspection | -| `operations` | Multi-step application workflows | -| `operations/projects` | Multi-step project creation workflows | -| `platform` | Platform capability and persistence service contracts | -| `relay` | Managed relay API and environment discovery | -| `rpc` | HTTP/RPC clients, protocol, sessions, and subscriptions | -| `state/` | Focused shared state, retention, reducers, and Atom constructors | +| Subpath | Responsibility | +| --------------------- | ----------------------------------------------------------------- | +| `authorization` | Bearer and DPoP authorization plus token persistence contracts | +| `connection` | Targets, catalog, supervision, retries, registry, and onboarding | +| `environment` | Environment identity, descriptors, endpoints, and scoped keys | +| `errors` | Shared client error inspection | +| `operations` | Multi-step application workflows | +| `operations/projects` | Multi-step project creation workflows | +| `platform` | Platform capability and persistence service contracts | +| `relay` | Managed relay API and environment discovery | +| `rpc` | HTTP/RPC clients, protocol, sessions, and subscriptions | +| `state/` | Focused shared state, retention, reducers, and Atom constructors | +| `voice-input` | Recording lifecycle, transcription contracts, and draft insertion | ## Dependency direction @@ -25,6 +26,11 @@ capabilities with `authorization`, `relay`, and `rpc` to supervise environment sessions. Independent `state` modules consume the connection registry and expose focused state or Atom constructors to application-owned runtimes. +The `voice-input` controller accepts capture callbacks and a selected `VoiceTranscriber`. +Preparation binds transcription to its implementation and resolved locale; one cancellation +signal covers both operations. Applications provide recorder events, permissions, native +transcription implementations, and presentation. + Applications should import the narrowest relevant subpath. There is no broad `state` export: use domain paths such as `state/shell`, `state/threads`, `state/terminal`, or `state/vcs`. Subpath indices and explicitly exported domain diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index abed33998966..d4b4f7170305 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -19,6 +19,26 @@ "types": "./src/markdownImages.ts", "default": "./src/markdownImages.ts" }, + "./markdown-links": { + "types": "./src/markdownLinks.ts", + "default": "./src/markdownLinks.ts" + }, + "./media-reference": { + "types": "./src/mediaReference.ts", + "default": "./src/mediaReference.ts" + }, + "./codex-file-citations": { + "types": "./src/codexFileCitations.ts", + "default": "./src/codexFileCitations.ts" + }, + "./codex-artifact-templates": { + "types": "./src/codexArtifactTemplates.ts", + "default": "./src/codexArtifactTemplates.ts" + }, + "./codex-markdown-directives": { + "types": "./src/codexMarkdownDirectives.ts", + "default": "./src/codexMarkdownDirectives.ts" + }, "./errors": { "types": "./src/errors/index.ts", "default": "./src/errors/index.ts" @@ -43,6 +63,10 @@ "types": "./src/providerSkills.ts", "default": "./src/providerSkills.ts" }, + "./voice-input": { + "types": "./src/voice-input/index.ts", + "default": "./src/voice-input/index.ts" + }, "./relay": { "types": "./src/relay/index.ts", "default": "./src/relay/index.ts" @@ -55,6 +79,10 @@ "types": "./src/state/assets.ts", "default": "./src/state/assets.ts" }, + "./state/attachments": { + "types": "./src/state/attachments.ts", + "default": "./src/state/attachments.ts" + }, "./state/connections": { "types": "./src/state/connections.ts", "default": "./src/state/connections.ts" @@ -154,6 +182,14 @@ "./state/vcs": { "types": "./src/state/vcs.ts", "default": "./src/state/vcs.ts" + }, + "./work-log/presentation": { + "types": "./src/work-log/presentation.ts", + "default": "./src/work-log/presentation.ts" + }, + "./work-log/command-label": { + "types": "./src/work-log/commandLabel.ts", + "default": "./src/work-log/commandLabel.ts" } }, "scripts": { @@ -163,10 +199,16 @@ "dependencies": { "@t3tools/contracts": "workspace:*", "@t3tools/shared": "workspace:*", - "effect": "catalog:" + "effect": "catalog:", + "mdast-util-directive": "^3.1.0", + "micromark-extension-directive": "^4.0.0", + "micromark-util-character": "^2.1.1", + "remark-parse": "^11.0.0", + "unified": "^11.0.5" }, "devDependencies": { "@effect/vitest": "catalog:", + "micromark-util-types": "^2.0.2", "vite-plus": "catalog:" } } diff --git a/packages/client-runtime/src/authorization/layer.test.ts b/packages/client-runtime/src/authorization/layer.test.ts index 466a5c2dd4ea..890a73d16964 100644 --- a/packages/client-runtime/src/authorization/layer.test.ts +++ b/packages/client-runtime/src/authorization/layer.test.ts @@ -6,6 +6,7 @@ import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; import * as TestClock from "effect/testing/TestClock"; +import { DPOP_UNKNOWN_HINT } from "../relay/errorPresentation.ts"; import * as ManagedRelay from "../relay/managedRelay.ts"; import { remoteHttpClientLayer } from "../rpc/http.ts"; import * as ClientCapabilities from "../platform/capabilities.ts"; @@ -174,6 +175,7 @@ describe("RemoteEnvironmentAuthorization", () => { httpBaseUrl: ENDPOINT.httpBaseUrl, wsBaseUrl: ENDPOINT.wsBaseUrl, bearerToken: "bearer-token", + connectionMethod: "direct", }); return [yield* authorize(), yield* authorize()] as const; }).pipe(Effect.provide(harness.layer)); @@ -211,6 +213,7 @@ describe("RemoteEnvironmentAuthorization", () => { httpBaseUrl: ENDPOINT.httpBaseUrl, wsBaseUrl: ENDPOINT.wsBaseUrl, bearerToken: "bearer-token", + connectionMethod: "direct", }); yield* authorize(); @@ -255,6 +258,7 @@ describe("RemoteEnvironmentAuthorization", () => { }).pipe(Effect.provide(harness.layer)); expect(authorized.socketUrl).toContain("wsTicket=cached-ticket"); + expect(authorized.socketUrl).toContain("connectionMethod=relay"); expect(yield* Ref.get(harness.bootstrapCalls)).toBe(0); expect(harness.fetch.calls).toHaveLength(1); expect(String(harness.fetch.calls[0]?.[0])).toBe( @@ -341,6 +345,29 @@ describe("RemoteEnvironmentAuthorization", () => { }), ); + it.effect("presents clock skew as one possible cause for a generic DPoP rejection", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ + responses: [Response.json(DESCRIPTOR), authInvalid()], + }); + + const failure = yield* Effect.gen(function* () { + const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization; + return yield* remote.authorizeDpop({ + expectedEnvironmentId: ENVIRONMENT_ID, + obtainBootstrap: harness.obtainBootstrap, + }); + }).pipe(Effect.provide(harness.layer), Effect.flip); + + expect(failure).toMatchObject({ + _tag: "ConnectionBlockedError", + reason: "authentication", + detail: `The environment credential is invalid. ${DPOP_UNKNOWN_HINT}`, + traceId: "trace-auth-invalid", + }); + }), + ); + it.effect("refreshes a cached endpoint after its first transient failure", () => Effect.gen(function* () { const cached = new TokenStore.RemoteDpopAccessToken({ diff --git a/packages/client-runtime/src/authorization/remote.test.ts b/packages/client-runtime/src/authorization/remote.test.ts index 6e6ccc86052d..0df4bab35ed8 100644 --- a/packages/client-runtime/src/authorization/remote.test.ts +++ b/packages/client-runtime/src/authorization/remote.test.ts @@ -7,6 +7,7 @@ import * as TestClock from "effect/testing/TestClock"; import { EnvironmentAuthInvalidError } from "@t3tools/contracts"; import { + appendClientConnectionParams, bootstrapRemoteBearerSession, exchangeRemoteDpopAccessToken, fetchRemoteDpopSessionState, @@ -209,6 +210,47 @@ describe("remote environment authorization", () => { }), ); + it.effect("keeps OS sentinels in telemetry but out of display metadata", () => + Effect.gen(function* () { + const tokenResponse = () => + Response.json( + { + access_token: "bearer-token", + issued_token_type: "urn:ietf:params:oauth:token-type:access_token", + token_type: "Bearer", + expires_in: 3600, + scope: "orchestration:read", + }, + { status: 200 }, + ); + const fetch = recordedFetch(tokenResponse(), tokenResponse()); + + for (const os of ["unknown", "other"] as const) { + yield* bootstrapRemoteBearerSession({ + httpBaseUrl: "https://remote.example.com/", + credential: "pairing-token", + clientMetadata: { + label: "T3 Code Web", + deviceType: "desktop", + os, + }, + }).pipe(provideRemoteHttp(fetch.fetchFn)); + } + + for (const [, init] of fetch.calls) { + expect(String(init.body)).not.toContain("client_os="); + } + + const websocketUrl = new URL("wss://remote.example.com/ws"); + appendClientConnectionParams(websocketUrl, { + surface: "web", + deviceType: "desktop", + os: "unknown", + }); + expect(websocketUrl.searchParams.get("clientOs")).toBe("unknown"); + }), + ); + it.effect("allows a client to explicitly narrow a pairing grant", () => Effect.gen(function* () { const fetch = recordedFetch( @@ -467,9 +509,20 @@ describe("remote environment authorization", () => { wsBaseUrl: "wss://remote.example.com/", httpBaseUrl: "https://remote.example.com/", bearerToken: "bearer-token", + clientMetadata: { + surface: "mobile", + appVersion: "1.2.3", + deviceType: "mobile", + os: "Android", + osMajorVersion: 15, + deviceModel: "Pixel 9", + }, + connectionMethod: "relay", }).pipe(provideRemoteHttp(fetch.fetchFn)); - expect(url).toBe("wss://remote.example.com/ws?wsTicket=ws-ticket"); + expect(url).toBe( + "wss://remote.example.com/ws?wsTicket=ws-ticket&clientSurface=mobile&clientAppVersion=1.2.3&clientDeviceType=phone&clientOs=Android&clientOsMajorVersion=15&clientDeviceModel=Pixel+9&connectionMethod=relay", + ); }), ); }); diff --git a/packages/client-runtime/src/authorization/remote.ts b/packages/client-runtime/src/authorization/remote.ts index 895fee836b3e..398a592e499d 100644 --- a/packages/client-runtime/src/authorization/remote.ts +++ b/packages/client-runtime/src/authorization/remote.ts @@ -3,6 +3,7 @@ import { type AuthClientPresentationMetadata, AuthEnvironmentBootstrapTokenType, AuthTokenExchangeGrantType, + type ClientConnectionMethod, type AuthEnvironmentScope, } from "@t3tools/contracts"; import { encodeOAuthScope } from "@t3tools/shared/oauthScope"; @@ -26,17 +27,23 @@ const DEFAULT_REMOTE_REQUEST_TIMEOUT_MS = 10_000; const clientMetadataTokenExchangeFields = ( clientMetadata: AuthClientPresentationMetadata | undefined, -) => ({ - ...(clientMetadata?.label ? { client_label: clientMetadata.label } : {}), - ...(clientMetadata?.deviceType ? { client_device_type: clientMetadata.deviceType } : {}), - ...(clientMetadata?.os ? { client_os: clientMetadata.os } : {}), -}); +) => { + const displayOs = clientMetadata?.os; + return { + ...(clientMetadata?.label ? { client_label: clientMetadata.label } : {}), + ...(clientMetadata?.deviceType ? { client_device_type: clientMetadata.deviceType } : {}), + ...(displayOs && displayOs !== "unknown" && displayOs !== "other" + ? { client_os: displayOs } + : {}), + }; +}; // The server reads these off the /ws upgrade URL next to wsTicket. Optional on // both ends: old servers ignore unknown params, old clients never send them. export const appendClientConnectionParams = ( url: URL, clientMetadata: AuthClientPresentationMetadata | undefined, + connectionMethod?: ClientConnectionMethod, ): void => { if (clientMetadata?.surface) { url.searchParams.set("clientSurface", clientMetadata.surface); @@ -44,6 +51,37 @@ export const appendClientConnectionParams = ( if (clientMetadata?.appVersion) { url.searchParams.set("clientAppVersion", clientMetadata.appVersion); } + if (clientMetadata?.deviceType) { + const deviceType = + clientMetadata.deviceType === "mobile" + ? "phone" + : clientMetadata.deviceType === "desktop" || clientMetadata.deviceType === "tablet" + ? clientMetadata.deviceType + : "unknown"; + url.searchParams.set("clientDeviceType", deviceType); + } + if (clientMetadata?.os) { + url.searchParams.set("clientOs", clientMetadata.os); + } + if (clientMetadata?.surface === "web") { + if (clientMetadata.webDeployment) { + url.searchParams.set("clientWebDeployment", clientMetadata.webDeployment); + } + if (clientMetadata.browser) { + url.searchParams.set("clientBrowser", clientMetadata.browser); + } + } + if (clientMetadata?.surface === "mobile") { + if (clientMetadata.osMajorVersion !== undefined) { + url.searchParams.set("clientOsMajorVersion", String(clientMetadata.osMajorVersion)); + } + if (clientMetadata.deviceModel) { + url.searchParams.set("clientDeviceModel", clientMetadata.deviceModel); + } + } + if (connectionMethod) { + url.searchParams.set("connectionMethod", connectionMethod); + } }; export const exchangeRemoteDpopAccessToken = Effect.fn( @@ -189,6 +227,7 @@ export const resolveRemoteWebSocketConnectionUrl = Effect.fn( readonly httpBaseUrl: string; readonly bearerToken: string; readonly clientMetadata?: AuthClientPresentationMetadata; + readonly connectionMethod?: ClientConnectionMethod; readonly timeoutMs?: number; }) { const issued = yield* issueRemoteWebSocketTicket({ @@ -202,7 +241,7 @@ export const resolveRemoteWebSocketConnectionUrl = Effect.fn( url.pathname = "/ws"; } url.searchParams.set("wsTicket", issued.ticket); - appendClientConnectionParams(url, input.clientMetadata); + appendClientConnectionParams(url, input.clientMetadata, input.connectionMethod); return url.toString(); }); @@ -214,6 +253,7 @@ export const resolveRemoteDpopWebSocketConnectionUrl = Effect.fn( readonly accessToken: string; readonly dpopProof: string; readonly clientMetadata?: AuthClientPresentationMetadata; + readonly connectionMethod?: ClientConnectionMethod; readonly timeoutMs?: number; }) { const issued = yield* issueRemoteDpopWebSocketTicket({ @@ -227,6 +267,6 @@ export const resolveRemoteDpopWebSocketConnectionUrl = Effect.fn( url.pathname = "/ws"; } url.searchParams.set("wsTicket", issued.ticket); - appendClientConnectionParams(url, input.clientMetadata); + appendClientConnectionParams(url, input.clientMetadata, input.connectionMethod); return url.toString(); }); diff --git a/packages/client-runtime/src/authorization/service.ts b/packages/client-runtime/src/authorization/service.ts index fef8db274b3a..b564916a9af0 100644 --- a/packages/client-runtime/src/authorization/service.ts +++ b/packages/client-runtime/src/authorization/service.ts @@ -1,4 +1,8 @@ -import { EnvironmentId, type ExecutionEnvironmentDescriptor } from "@t3tools/contracts"; +import { + type ClientConnectionMethod, + EnvironmentId, + type ExecutionEnvironmentDescriptor, +} from "@t3tools/contracts"; import type { RelayManagedEndpoint } from "@t3tools/contracts/relay"; import { exchangeRemoteDpopAccessToken, @@ -6,7 +10,11 @@ import { resolveRemoteDpopWebSocketConnectionUrl, resolveRemoteWebSocketConnectionUrl, } from "./remote.ts"; -import { environmentMismatchError, mapRemoteEnvironmentError } from "../connection/errors.ts"; +import { + environmentMismatchError, + mapRemoteDpopEnvironmentError, + mapRemoteEnvironmentError, +} from "../connection/errors.ts"; import { ConnectionBlockedError, type ConnectionAttemptError } from "../connection/model.ts"; import { fetchRemoteEnvironmentDescriptor } from "../environment/descriptor.ts"; import { environmentEndpointUrl } from "../environment/endpoint.ts"; @@ -46,6 +54,7 @@ export class RemoteEnvironmentAuthorization extends Context.Service< readonly httpBaseUrl: string; readonly wsBaseUrl: string; readonly bearerToken: string; + readonly connectionMethod: ClientConnectionMethod; }) => Effect.Effect; readonly authorizeDpop: (input: { readonly expectedEnvironmentId: EnvironmentId; @@ -64,7 +73,7 @@ const BEARER_DESCRIPTOR_CACHE_TTL_MS = 10_000; function mapDpopSocketError(error: RemoteEnvironmentAuthError | ConnectionAttemptError) { return error._tag === "ConnectionTransientError" || error._tag === "ConnectionBlockedError" ? error - : mapRemoteEnvironmentError(error); + : mapRemoteDpopEnvironmentError(error); } const fetchDescriptor = Effect.fn("clientRuntime.connection.remote.fetchDescriptor")(function* ( @@ -99,6 +108,7 @@ export const make = Effect.gen(function* () { readonly httpBaseUrl: string; readonly wsBaseUrl: string; readonly bearerToken: string; + readonly connectionMethod: ClientConnectionMethod; }) { const now = yield* Clock.currentTimeMillis; const cachedDescriptor = (yield* Ref.get(bearerDescriptors)).get(input.expectedEnvironmentId); @@ -132,6 +142,7 @@ export const make = Effect.gen(function* () { httpBaseUrl: input.httpBaseUrl, bearerToken: input.bearerToken, clientMetadata: presentation.metadata, + connectionMethod: input.connectionMethod, }).pipe( Effect.mapError(mapRemoteEnvironmentError), Effect.provideService(HttpClient.HttpClient, httpClient), @@ -172,6 +183,7 @@ export const make = Effect.gen(function* () { accessToken: token.accessToken, dpopProof: ticketProof, clientMetadata: presentation.metadata, + connectionMethod: "relay", ...(timeoutMs === undefined ? {} : { timeoutMs }), }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient)); }, @@ -268,7 +280,7 @@ export const make = Effect.gen(function* () { scopes: presentation.scopes, clientMetadata: presentation.metadata, }).pipe( - Effect.mapError(mapRemoteEnvironmentError), + Effect.mapError(mapRemoteDpopEnvironmentError), Effect.provideService(HttpClient.HttpClient, httpClient), Effect.withSpan("environment.authorization.accessToken.exchange"), ); diff --git a/packages/client-runtime/src/codexArtifactTemplates.test.ts b/packages/client-runtime/src/codexArtifactTemplates.test.ts new file mode 100644 index 000000000000..d92236104a56 --- /dev/null +++ b/packages/client-runtime/src/codexArtifactTemplates.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + appendCodexArtifactTemplateUsePrompt, + codexArtifactTemplatePresentationLabel, + codexArtifactTemplateUsePrompt, + resolveCodexArtifactTemplate, + type CodexArtifactTemplate, +} from "./codexArtifactTemplates.js"; + +const HELLO_WORLD_TEMPLATE: CodexArtifactTemplate = { + artifactKind: "document", + displayName: "Hello World", + skillDirectory: "/Users/test/.codex/skills/artifact-template-hello-world", + skillName: "artifact-template-hello-world", +}; + +describe("artifact template presentation", () => { + it("shares labels and copy text across clients", () => { + expect(codexArtifactTemplatePresentationLabel("document")).toBe("Document template"); + }); +}); + +describe("resolveCodexArtifactTemplate", () => { + it("accepts the template metadata emitted by Codex", () => { + expect( + resolveCodexArtifactTemplate({ + artifact_kind: "document", + display_name: " Hello World ", + skill_directory: "/Users/test/.codex/skills/artifact-template-hello-world", + skill_name: "artifact-template-hello-world", + }), + ).toEqual(HELLO_WORLD_TEMPLATE); + }); + + it.each([ + String.raw`C:\Users\test\.codex\skills\artifact-template-hello-world`, + String.raw`\\server\share\artifact-template-hello-world`, + "//server/share/artifact-template-hello-world", + ])("accepts absolute Windows skill directories: %s", (skillDirectory) => { + expect( + resolveCodexArtifactTemplate({ + artifact_kind: "image", + display_name: "Reference image", + gallery_kind: "product-design", + skill_directory: skillDirectory, + skill_name: "artifact-template-reference-image", + }), + ).toMatchObject({ + artifactKind: "image", + galleryKind: "product-design", + skillDirectory, + }); + }); + + it.each([ + { artifact_kind: "unknown" }, + { display_name: " " }, + { skill_directory: "relative/template" }, + { skill_name: "hello-world" }, + { gallery_kind: null }, + { gallery_kind: "unknown" }, + ])("rejects malformed template metadata: %o", (override) => { + expect( + resolveCodexArtifactTemplate({ + artifact_kind: "document", + display_name: "Hello World", + skill_directory: "/templates/hello-world", + skill_name: "artifact-template-hello-world", + ...override, + }), + ).toBeNull(); + }); +}); + +describe("codexArtifactTemplateUsePrompt", () => { + it("builds the same document follow-up shape as Codex", () => { + expect(codexArtifactTemplateUsePrompt(HELLO_WORLD_TEMPLATE)).toBe( + "Create a document using this $artifact-template-hello-world about…", + ); + }); + + it("uses the artifact-specific image wording", () => { + expect( + codexArtifactTemplateUsePrompt({ + ...HELLO_WORLD_TEMPLATE, + artifactKind: "image", + }), + ).toBe("Create an image using this $artifact-template-hello-world of…"); + }); +}); + +describe("appendCodexArtifactTemplateUsePrompt", () => { + const prompt = "Create a document using this $artifact-template-hello-world about…"; + + it("adds the prompt to an empty draft", () => { + expect(appendCodexArtifactTemplateUsePrompt("", HELLO_WORLD_TEMPLATE)).toBe(prompt); + }); + + it("preserves existing draft text", () => { + expect(appendCodexArtifactTemplateUsePrompt("Write about otters", HELLO_WORLD_TEMPLATE)).toBe( + `Write about otters ${prompt}`, + ); + }); + + it.each([prompt, `${prompt}\n`, `Notes\n\n${prompt}`])( + "does not append the same final prompt twice: %s", + (draft) => { + expect(appendCodexArtifactTemplateUsePrompt(draft, HELLO_WORLD_TEMPLATE)).toBe(draft); + }, + ); + + it("does not mistake text containing the prompt for a final prompt", () => { + const draft = `${prompt}\nAdditional instructions`; + expect(appendCodexArtifactTemplateUsePrompt(draft, HELLO_WORLD_TEMPLATE)).toBe( + `${draft} ${prompt}`, + ); + }); +}); diff --git a/packages/client-runtime/src/codexArtifactTemplates.ts b/packages/client-runtime/src/codexArtifactTemplates.ts new file mode 100644 index 000000000000..ad46a7d6c2b0 --- /dev/null +++ b/packages/client-runtime/src/codexArtifactTemplates.ts @@ -0,0 +1,136 @@ +export const CODEX_ARTIFACT_TEMPLATE_KINDS = [ + "document", + "presentation", + "spreadsheet", + "site", + "google-docs", + "google-slides", + "google-sheets", + "image", + "email", + "slack", +] as const; + +export type CodexArtifactTemplateKind = (typeof CODEX_ARTIFACT_TEMPLATE_KINDS)[number]; + +export const CODEX_ARTIFACT_TEMPLATE_GALLERY_KINDS = ["imagegen", "product-design"] as const; + +export type CodexArtifactTemplateGalleryKind = + (typeof CODEX_ARTIFACT_TEMPLATE_GALLERY_KINDS)[number]; + +export interface CodexArtifactTemplate { + readonly artifactKind: CodexArtifactTemplateKind; + readonly displayName: string; + readonly galleryKind?: CodexArtifactTemplateGalleryKind; + readonly skillDirectory: string; + readonly skillName: string; +} + +export const CODEX_ARTIFACT_TEMPLATE_LABEL_BY_KIND = { + document: "Document template", + presentation: "Presentation template", + spreadsheet: "Spreadsheet template", + site: "Site template", + "google-docs": "Google Doc template", + "google-slides": "Google Slides template", + "google-sheets": "Google Sheet template", + image: "Image template", + email: "Email template", + slack: "Slack template", +} satisfies Record; + +export type CodexArtifactTemplateAttributes = Readonly>; + +const WINDOWS_DRIVE_PATH_REGEX = /^[A-Za-z]:[\\/]/; +const WINDOWS_UNC_PATH_REGEX = /^(?:\\\\[^\\]+\\[^\\]+|\/\/[^/]+\/[^/]+)/; + +function isCodexArtifactTemplateKind(value: unknown): value is CodexArtifactTemplateKind { + return CODEX_ARTIFACT_TEMPLATE_KINDS.some((kind) => kind === value); +} + +function isCodexArtifactTemplateGalleryKind( + value: unknown, +): value is CodexArtifactTemplateGalleryKind { + return CODEX_ARTIFACT_TEMPLATE_GALLERY_KINDS.some((kind) => kind === value); +} + +function isAbsoluteSkillDirectory(value: string): boolean { + return ( + (value.startsWith("/") && !value.startsWith("//")) || + WINDOWS_DRIVE_PATH_REGEX.test(value) || + WINDOWS_UNC_PATH_REGEX.test(value) + ); +} + +/** Mirrors the Codex result-card schema so malformed directives remain literal Markdown. */ +export function resolveCodexArtifactTemplate( + attributes: CodexArtifactTemplateAttributes | null | undefined, +): CodexArtifactTemplate | null { + const artifactKind = attributes?.artifact_kind; + const displayNameValue = attributes?.display_name; + const displayName = typeof displayNameValue === "string" ? displayNameValue.trim() : undefined; + const galleryKind = attributes?.gallery_kind; + const skillDirectory = attributes?.skill_directory; + const skillName = attributes?.skill_name; + + if ( + !isCodexArtifactTemplateKind(artifactKind) || + !displayName || + typeof skillDirectory !== "string" || + !isAbsoluteSkillDirectory(skillDirectory) || + typeof skillName !== "string" || + !skillName.startsWith("artifact-template-") || + (galleryKind !== undefined && !isCodexArtifactTemplateGalleryKind(galleryKind)) + ) { + return null; + } + + return { + artifactKind, + displayName, + ...(galleryKind === undefined ? {} : { galleryKind }), + skillDirectory, + skillName, + }; +} + +const USE_PROMPT_BY_KIND: Record string> = { + document: (skill) => `Create a document using this ${skill} about…`, + presentation: (skill) => `Create a presentation using the ${skill} template about…`, + spreadsheet: (skill) => `Create a spreadsheet using this ${skill} about…`, + site: (skill) => `Create a Site using this ${skill} about…`, + "google-docs": (skill) => `Create a Google Doc using this ${skill} about…`, + "google-slides": (skill) => `Create a Google Slides presentation using this ${skill} about…`, + "google-sheets": (skill) => `Create a Google Sheet using this ${skill} about…`, + image: (skill) => `Create an image using this ${skill} of…`, + email: (skill) => `Draft an email using this ${skill} about…`, + slack: (skill) => `Draft a Slack message using this ${skill} about…`, +}; + +export function codexArtifactTemplateUsePrompt(template: CodexArtifactTemplate): string { + return USE_PROMPT_BY_KIND[template.artifactKind](`$${template.skillName}`); +} + +export function codexArtifactTemplatePresentationLabel(kind: CodexArtifactTemplateKind): string { + return CODEX_ARTIFACT_TEMPLATE_LABEL_BY_KIND[kind]; +} + +export function appendCodexArtifactTemplateUsePrompt( + draft: string, + template: CodexArtifactTemplate, +): string { + const prompt = codexArtifactTemplateUsePrompt(template); + const trimmedDraft = draft.trimEnd(); + const promptStart = trimmedDraft.length - prompt.length; + const alreadyEndsWithPrompt = + promptStart >= 0 && + trimmedDraft.slice(promptStart) === prompt && + (promptStart === 0 || /\s/.test(trimmedDraft[promptStart - 1] ?? "")); + + if (alreadyEndsWithPrompt) { + return draft; + } + + const needsLeadingSpace = draft.length > 0 && !/\s$/.test(draft); + return `${draft}${needsLeadingSpace ? " " : ""}${prompt}`; +} diff --git a/packages/client-runtime/src/codexFileCitations.test.ts b/packages/client-runtime/src/codexFileCitations.test.ts new file mode 100644 index 000000000000..d132a5480ca4 --- /dev/null +++ b/packages/client-runtime/src/codexFileCitations.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { codexFileCitationMarkdown, resolveCodexFileCitationLink } from "./codexFileCitations.js"; + +describe("resolveCodexFileCitationLink", () => { + it("resolves the attributes emitted by Codex", () => { + expect( + resolveCodexFileCitationLink({ + path: "/workspace/outputs/issue-2387-sparse-diagonal.xlsx", + purpose: "output", + }), + ).toEqual({ + path: "/workspace/outputs/issue-2387-sparse-diagonal.xlsx", + href: "/workspace/outputs/issue-2387-sparse-diagonal.xlsx", + label: "issue-2387-sparse-diagonal.xlsx", + }); + }); + + it("carries the first cited line into the file href", () => { + expect( + resolveCodexFileCitationLink({ + path: "src/main.ts", + line_range_start: "42", + line_range_end: "48", + git_url: "https://example.com/main.ts", + }), + ).toEqual({ + path: "src/main.ts", + href: "src/main.ts#L42", + label: "main.ts", + lineRangeStart: 42, + }); + }); + + it("rejects missing paths and invalid line numbers", () => { + expect(resolveCodexFileCitationLink({ purpose: "output" })).toBeNull(); + expect( + resolveCodexFileCitationLink({ path: "src/main.ts", line_range_start: "not-a-line" }), + ).toEqual({ + path: "src/main.ts", + href: "src/main.ts", + label: "main.ts", + }); + }); + + it("preserves URL syntax characters in file paths", () => { + expect( + resolveCodexFileCitationLink({ + path: "reports/100% #1? draft.md", + line_range_start: "7", + }), + ).toEqual({ + path: "reports/100% #1? draft.md", + href: "reports/100%25 %231%3F draft.md#L7", + label: "100% #1? draft.md", + lineRangeStart: 7, + }); + }); +}); + +describe("codexFileCitationMarkdown", () => { + it("produces a portable Markdown link", () => { + const citation = resolveCodexFileCitationLink({ path: "reports/profit and loss.xlsx" }); + expect(citation && codexFileCitationMarkdown(citation)).toBe( + "[profit and loss.xlsx]()", + ); + }); + + it("escapes Markdown syntax in the visible filename", () => { + const citation = resolveCodexFileCitationLink({ + path: "reports/*draft*_[copy]`<&.txt", + }); + expect(citation && codexFileCitationMarkdown(citation)).toBe( + "[\\*draft\\*\\_\\[copy\\]\\`\\<\\&.txt]()", + ); + }); +}); diff --git a/packages/client-runtime/src/codexFileCitations.ts b/packages/client-runtime/src/codexFileCitations.ts new file mode 100644 index 000000000000..b5042f0806e6 --- /dev/null +++ b/packages/client-runtime/src/codexFileCitations.ts @@ -0,0 +1,55 @@ +export interface CodexFileCitationLink { + readonly path: string; + readonly href: string; + readonly label: string; + readonly lineRangeStart?: number; +} + +export type CodexFileCitationAttributes = Readonly>; + +function positiveInteger(value: string | null | undefined): number | undefined { + if (value === null || value === undefined || value.trim().length === 0) return undefined; + const parsed = Number(value); + return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : undefined; +} + +function citationLabel(path: string): string { + const normalized = path.replaceAll("\\", "/").replace(/\/+$/, ""); + return normalized.slice(normalized.lastIndexOf("/") + 1) || normalized || "File"; +} + +function markdownDestinationPath(path: string): string { + return path.replaceAll("%", "%25").replaceAll("#", "%23").replaceAll("?", "%3F"); +} + +export function resolveCodexFileCitationLink( + attributes: CodexFileCitationAttributes | null | undefined, +): CodexFileCitationLink | null { + const path = attributes?.path?.trim(); + if (!path) return null; + + const lineRangeStart = positiveInteger(attributes?.line_range_start); + const destinationPath = markdownDestinationPath(path); + return { + path, + href: lineRangeStart === undefined ? destinationPath : `${destinationPath}#L${lineRangeStart}`, + label: citationLabel(path), + ...(lineRangeStart === undefined ? {} : { lineRangeStart }), + }; +} + +function markdownLabel(value: string): string { + return value.replace(/[\\[\]*_`<&]/g, "\\$&"); +} + +function markdownDestination(value: string): string { + return value + .replaceAll("<", "%3C") + .replaceAll(">", "%3E") + .replaceAll("\r", "%0D") + .replaceAll("\n", "%0A"); +} + +export function codexFileCitationMarkdown(citation: CodexFileCitationLink): string { + return `[${markdownLabel(citation.label)}](<${markdownDestination(citation.href)}>)`; +} diff --git a/packages/client-runtime/src/codexMarkdownDirectives.test.ts b/packages/client-runtime/src/codexMarkdownDirectives.test.ts new file mode 100644 index 000000000000..b2a22901eac2 --- /dev/null +++ b/packages/client-runtime/src/codexMarkdownDirectives.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, it } from "vite-plus/test"; +import remarkParse from "remark-parse"; +import { unified } from "unified"; + +import { + remarkCodexDirectives, + renderCodexDirectivesForCopy, + renderCodexFileCitationsAsMarkdown, + splitCodexArtifactTemplateMarkdown, +} from "./codexMarkdownDirectives.js"; + +interface TestNode { + readonly type: string; + readonly value?: string; + readonly url?: string; + readonly position?: { + readonly start: { readonly offset?: number }; + readonly end: { readonly offset?: number }; + }; + readonly data?: { + readonly hName?: string; + readonly hProperties?: Readonly>; + }; + readonly children?: readonly TestNode[]; +} + +const FILE_CITATION = ':codex-file-citation{path="outputs/report.xlsx" purpose="output"}'; +const ARTIFACT_TEMPLATE = + '::artifact-template{skill_name="artifact-template-hello-world" skill_directory="/Users/test/.codex/skills/artifact-template-hello-world" display_name="Hello World" artifact_kind="document"}'; + +function parse(markdown: string): TestNode { + const processor = unified().use(remarkParse).use(remarkCodexDirectives); + return processor.runSync(processor.parse(markdown), { value: markdown }) as TestNode; +} + +function parseOrdinaryMarkdown(markdown: string): TestNode { + return unified().use(remarkParse).parse(markdown) as TestNode; +} + +describe("remarkCodexDirectives", () => { + it("renders a file citation as a link without changing its source position", () => { + const markdown = `Created ${FILE_CITATION}.`; + const link = parse(markdown).children?.[0]?.children?.[1]; + + expect(link).toMatchObject({ + type: "link", + url: "outputs/report.xlsx", + children: [{ type: "text", value: "report.xlsx" }], + position: { + start: { offset: markdown.indexOf(FILE_CITATION) }, + end: { offset: markdown.indexOf(FILE_CITATION) + FILE_CITATION.length }, + }, + }); + }); + + it("renders an artifact template as semantic block metadata", () => { + expect(parse(ARTIFACT_TEMPLATE).children?.[0]).toMatchObject({ + type: "paragraph", + children: [], + data: { + hName: "div", + hProperties: { + dataCodexArtifactTemplate: "true", + dataArtifactKind: "document", + dataDisplayName: "Hello World", + dataSkillName: "artifact-template-hello-world", + }, + }, + }); + }); + + it.each([ + "Meeting at 10:30", + "Open src/main.ts:42", + "Use :hover and :tada:", + "::note", + ":::note\ncontent\n:::", + ':codex-file-citation-extra{path="outputs/report.xlsx"}', + "::artifact-template-extra", + ])("does not change unrelated colon syntax: %s", (markdown) => { + expect(parse(markdown)).toEqual(parseOrdinaryMarkdown(markdown)); + }); + + it.each([ + ':codex-file-citation{purpose="output"}', + '::artifact-template{skill_name="artifact-template-hello-world"}', + ])("keeps malformed supported directives literal: %s", (markdown) => { + expect(parse(markdown)).toEqual(parseOrdinaryMarkdown(markdown)); + }); +}); + +describe("native Markdown adapters", () => { + it("uses the same parser to render file citations as portable links", () => { + expect(renderCodexFileCitationsAsMarkdown(`Created ${FILE_CITATION}.`)).toBe( + "Created [report.xlsx]().", + ); + }); + + it.each([ + `\\${FILE_CITATION}`, + `\`${FILE_CITATION}\``, + `\`\`\`text\n${FILE_CITATION}\n\`\`\``, + `[See ${FILE_CITATION}](https://example.com)`, + ])("does not render excluded citation syntax: %s", (markdown) => { + expect(renderCodexFileCitationsAsMarkdown(markdown)).toBe(markdown); + }); + + it("splits artifact cards from surrounding native Markdown", () => { + expect(splitCodexArtifactTemplateMarkdown(`Before\n\n${ARTIFACT_TEMPLATE}\n\nAfter`)).toEqual([ + { kind: "markdown", markdown: "Before\n\n", sourceOffset: 0 }, + { + kind: "artifact-template", + sourceOffset: 8, + template: { + artifactKind: "document", + displayName: "Hello World", + skillDirectory: "/Users/test/.codex/skills/artifact-template-hello-world", + skillName: "artifact-template-hello-world", + }, + }, + { + kind: "markdown", + markdown: "\n\nAfter", + sourceOffset: 8 + ARTIFACT_TEMPLATE.length, + }, + ]); + }); + + it("leaves malformed and code artifact-template examples in Markdown", () => { + const malformed = '::artifact-template{display_name="Hello World"}'; + const code = `\`${ARTIFACT_TEMPLATE}\``; + expect(splitCodexArtifactTemplateMarkdown(malformed)).toEqual([ + { kind: "markdown", markdown: malformed, sourceOffset: 0 }, + ]); + expect(splitCodexArtifactTemplateMarkdown(code)).toEqual([ + { kind: "markdown", markdown: code, sourceOffset: 0 }, + ]); + }); +}); + +describe("directive copy adapter", () => { + it("copies the Markdown representations shown by citation chips and template cards", () => { + expect(renderCodexDirectivesForCopy(`Created ${FILE_CITATION}.\n\n${ARTIFACT_TEMPLATE}`)).toBe( + "Created [report.xlsx]().\n\nHello World (Document template)", + ); + }); + + it("leaves excluded and malformed directive source unchanged", () => { + const markdown = [ + `\`${FILE_CITATION}\``, + '::artifact-template{display_name="Hello World"}', + ].join("\n\n"); + + expect(renderCodexDirectivesForCopy(markdown)).toBe(markdown); + }); +}); diff --git a/packages/client-runtime/src/codexMarkdownDirectives.ts b/packages/client-runtime/src/codexMarkdownDirectives.ts new file mode 100644 index 000000000000..64a981a6b796 --- /dev/null +++ b/packages/client-runtime/src/codexMarkdownDirectives.ts @@ -0,0 +1,388 @@ +import { directiveFromMarkdown } from "mdast-util-directive"; +import { directive } from "micromark-extension-directive"; +import { + markdownLineEnding, + unicodePunctuation, + unicodeWhitespace, +} from "micromark-util-character"; +import type { Construct, Extension, Tokenizer } from "micromark-util-types"; +import remarkParse from "remark-parse"; +import { unified, type Processor } from "unified"; + +import { + codexArtifactTemplatePresentationLabel, + resolveCodexArtifactTemplate, + type CodexArtifactTemplate, +} from "./codexArtifactTemplates.ts"; +import { codexFileCitationMarkdown, resolveCodexFileCitationLink } from "./codexFileCitations.ts"; + +const COLON = 58; +const DASH = 45; +const UNDERSCORE = 95; +const CODEX_FILE_CITATION_NAME = "codex-file-citation"; +const CODEX_ARTIFACT_TEMPLATE_NAME = "artifact-template"; + +export const CODEX_ARTIFACT_TEMPLATE_HAST_PROPERTIES = [ + "dataCodexArtifactTemplate", + "dataArtifactKind", + "dataDisplayName", + "dataGalleryKind", + "dataSkillDirectory", + "dataSkillName", +] as const; + +interface MarkdownPosition { + readonly start: { readonly offset?: number }; + readonly end: { readonly offset?: number }; +} + +interface MarkdownAstNode { + type?: string; + name?: string; + value?: string; + url?: string; + attributes?: Readonly>; + position?: MarkdownPosition; + data?: { + codexArtifactTemplate?: CodexArtifactTemplate; + codexFileCitationMarkdown?: string; + hName?: string; + hProperties?: Record; + }; + children?: MarkdownAstNode[]; +} + +interface MarkdownFile { + readonly value: unknown; +} + +export type CodexArtifactTemplateMarkdownSegment = + | { readonly kind: "markdown"; readonly markdown: string; readonly sourceOffset: number } + | { + readonly kind: "artifact-template"; + readonly sourceOffset: number; + readonly template: CodexArtifactTemplate; + }; + +function asConstruct(value: Construct | Construct[] | undefined, label: string): Construct { + const construct = Array.isArray(value) ? value[0] : value; + if (!construct) throw new Error(`Missing ${label} directive construct`); + return construct; +} + +function directiveNameEnds(code: number | null): boolean { + return ( + code === null || + markdownLineEnding(code) || + unicodeWhitespace(code) || + (unicodePunctuation(code) && code !== DASH && code !== UNDERSCORE) + ); +} + +function directiveNameGate(markerCount: number, name: string): Construct { + const tokenize: Tokenizer = (effects, ok, nok) => { + let markerIndex = 0; + let nameIndex = 0; + + return marker; + + function marker(code: number | null) { + if (code !== COLON) return nok(code); + if (markerIndex === 0) effects.enter("data"); + effects.consume(code); + markerIndex += 1; + return markerIndex === markerCount ? nameCharacter : marker; + } + + function nameCharacter(code: number | null) { + if (code !== name.charCodeAt(nameIndex)) return nok(code); + effects.consume(code); + nameIndex += 1; + return nameIndex === name.length ? afterName : nameCharacter; + } + + function afterName(code: number | null) { + effects.exit("data"); + return directiveNameEnds(code) ? ok(code) : nok(code); + } + }; + + return { partial: true, tokenize }; +} + +function restrictedDirective(construct: Construct, markerCount: number, name: string): Construct { + const gate = directiveNameGate(markerCount, name); + return { + ...construct, + tokenize(effects, ok, nok) { + return effects.check(gate, construct.tokenize.call(this, effects, ok, nok), nok); + }, + }; +} + +function codexDirectiveSyntax(): Extension { + const genericSyntax = directive(); + const textDirective = asConstruct(genericSyntax.text?.[COLON], CODEX_FILE_CITATION_NAME); + const flowDirectives = genericSyntax.flow?.[COLON]; + const leafDirective = Array.isArray(flowDirectives) + ? flowDirectives.find((construct) => construct.concrete !== true) + : flowDirectives; + if (!leafDirective) + throw new Error(`Missing ${CODEX_ARTIFACT_TEMPLATE_NAME} directive construct`); + + return { + text: { + [COLON]: restrictedDirective(textDirective, 1, CODEX_FILE_CITATION_NAME), + }, + flow: { + [COLON]: restrictedDirective(leafDirective, 2, CODEX_ARTIFACT_TEMPLATE_NAME), + }, + }; +} + +const CODEX_DIRECTIVE_SYNTAX = codexDirectiveSyntax(); +const CODEX_DIRECTIVE_FROM_MARKDOWN = directiveFromMarkdown(); + +function sourceForNode(node: MarkdownAstNode, source: string): string { + const start = node.position?.start.offset; + const end = node.position?.end.offset; + return start === undefined || end === undefined ? "" : source.slice(start, end); +} + +function sourceForDirective(node: MarkdownAstNode, source: string, marker: ":" | "::"): string { + const prefix = `${marker}${node.name ?? ""}`; + const slicedSource = sourceForNode(node, source); + if (slicedSource.startsWith(prefix)) return slicedSource; + + const attributes = Object.entries(node.attributes ?? {}).map(([name, value]) => + value === null ? name : `${name}=${JSON.stringify(value)}`, + ); + return `${prefix}${attributes.length === 0 ? "" : `{${attributes.join(" ")}}`}`; +} + +function restoreTextDirective(node: MarkdownAstNode, source: string): void { + node.type = "text"; + node.value = sourceForDirective(node, source, ":"); + delete node.name; + delete node.attributes; + delete node.url; + delete node.data; + delete node.children; +} + +function restoreLeafDirective(node: MarkdownAstNode, source: string): void { + const value = sourceForDirective(node, source, "::"); + node.type = "paragraph"; + node.children = [ + { type: "text", value, ...(node.position === undefined ? {} : { position: node.position }) }, + ]; + delete node.name; + delete node.attributes; + delete node.value; + delete node.url; + delete node.data; +} + +function renderFileCitation(node: MarkdownAstNode, source: string, insideLink: boolean): void { + const citation = resolveCodexFileCitationLink(node.attributes); + if (!citation || insideLink) { + restoreTextDirective(node, source); + return; + } + + node.type = "link"; + node.url = citation.href; + node.children = [{ type: "text", value: citation.label }]; + node.data = { codexFileCitationMarkdown: codexFileCitationMarkdown(citation) }; + delete node.name; + delete node.attributes; + delete node.value; +} + +function renderArtifactTemplate(node: MarkdownAstNode, source: string): void { + const template = resolveCodexArtifactTemplate(node.attributes); + if (!template) { + restoreLeafDirective(node, source); + return; + } + + node.type = "paragraph"; + node.children = []; + node.data = { + codexArtifactTemplate: template, + hName: "div", + hProperties: { + dataCodexArtifactTemplate: "true", + dataArtifactKind: template.artifactKind, + dataDisplayName: template.displayName, + ...(template.galleryKind === undefined ? {} : { dataGalleryKind: template.galleryKind }), + dataSkillDirectory: template.skillDirectory, + dataSkillName: template.skillName, + }, + }; + delete node.name; + delete node.attributes; + delete node.value; + delete node.url; +} + +function transformCodexDirectives(node: MarkdownAstNode, source: string, insideLink = false): void { + if (node.type === "textDirective" && node.name === CODEX_FILE_CITATION_NAME) { + renderFileCitation(node, source, insideLink); + return; + } + if (node.type === "leafDirective" && node.name === CODEX_ARTIFACT_TEMPLATE_NAME) { + renderArtifactTemplate(node, source); + return; + } + + const childrenInsideLink = insideLink || node.type === "link" || node.type === "linkReference"; + for (const child of node.children ?? []) { + transformCodexDirectives(child, source, childrenInsideLink); + } +} + +/** Adds grammar only for the two directives emitted by Codex, then renders them as mdast. */ +function attachCodexDirectives(this: Processor) { + const data = this.data(); + const micromarkExtensions = data.micromarkExtensions ?? (data.micromarkExtensions = []); + const fromMarkdownExtensions = data.fromMarkdownExtensions ?? (data.fromMarkdownExtensions = []); + micromarkExtensions.push(CODEX_DIRECTIVE_SYNTAX); + fromMarkdownExtensions.push(CODEX_DIRECTIVE_FROM_MARKDOWN); + + return (tree: unknown, file: MarkdownFile) => { + transformCodexDirectives(tree as MarkdownAstNode, String(file.value)); + }; +} + +export const remarkCodexDirectives = attachCodexDirectives; + +const directiveParser = unified().use(remarkParse).use(remarkCodexDirectives).freeze(); + +function parseCodexMarkdown(markdown: string): MarkdownAstNode { + return directiveParser.runSync(directiveParser.parse(markdown), { + value: markdown, + }) as MarkdownAstNode; +} + +interface DirectiveMatch { + readonly start: number; + readonly end: number; + readonly markdown?: string; + readonly template?: CodexArtifactTemplate; +} + +function collectDirectiveMatches(node: MarkdownAstNode, matches: DirectiveMatch[]): void { + const start = node.position?.start.offset; + const end = node.position?.end.offset; + if (start !== undefined && end !== undefined) { + if (node.data?.codexFileCitationMarkdown !== undefined) { + matches.push({ start, end, markdown: node.data.codexFileCitationMarkdown }); + return; + } + if (node.data?.codexArtifactTemplate !== undefined) { + matches.push({ start, end, template: node.data.codexArtifactTemplate }); + return; + } + } + for (const child of node.children ?? []) collectDirectiveMatches(child, matches); +} + +function renderDirectiveMatches( + markdown: string, + replacementFor: (match: DirectiveMatch) => string | undefined, +): string { + const matches: DirectiveMatch[] = []; + collectDirectiveMatches(parseCodexMarkdown(markdown), matches); + let rendered = markdown; + for (const match of matches.sort((left, right) => right.start - left.start)) { + const replacement = replacementFor(match); + if (replacement !== undefined) { + rendered = rendered.slice(0, match.start) + replacement + rendered.slice(match.end); + } + } + return rendered; +} + +/** Native Markdown renderers use this adapter because they cannot consume a Remark tree. */ +export function renderCodexFileCitationsAsMarkdown(markdown: string): string { + if (!markdown.includes(`:${CODEX_FILE_CITATION_NAME}`)) return markdown; + + return renderDirectiveMatches(markdown, (match) => match.markdown); +} + +/** Matches the Markdown emitted when users copy rendered Codex directive UI. */ +export function renderCodexDirectivesForCopy(markdown: string): string { + if ( + !markdown.includes(`:${CODEX_FILE_CITATION_NAME}`) && + !markdown.includes(`::${CODEX_ARTIFACT_TEMPLATE_NAME}`) + ) { + return markdown; + } + + return renderDirectiveMatches(markdown, (match) => { + if (match.markdown !== undefined) return match.markdown; + if (match.template === undefined) return undefined; + return `${match.template.displayName} (${codexArtifactTemplatePresentationLabel(match.template.artifactKind)})`; + }); +} + +/** Native renderers split cards out because they cannot host a view inside Markdown text. */ +export function splitCodexArtifactTemplateMarkdown( + markdown: string, +): ReadonlyArray { + if (!markdown.includes(`::${CODEX_ARTIFACT_TEMPLATE_NAME}`)) { + return [{ kind: "markdown", markdown, sourceOffset: 0 }]; + } + + const matches: DirectiveMatch[] = []; + collectDirectiveMatches(parseCodexMarkdown(markdown), matches); + const templates = matches + .filter( + (match): match is DirectiveMatch & { readonly template: CodexArtifactTemplate } => + match.template !== undefined, + ) + .sort((left, right) => left.start - right.start); + if (templates.length === 0) { + return [{ kind: "markdown", markdown, sourceOffset: 0 }]; + } + + const segments: CodexArtifactTemplateMarkdownSegment[] = []; + let cursor = 0; + for (const match of templates) { + if (match.start > cursor) { + segments.push({ + kind: "markdown", + markdown: markdown.slice(cursor, match.start), + sourceOffset: cursor, + }); + } + segments.push({ + kind: "artifact-template", + sourceOffset: match.start, + template: match.template, + }); + cursor = match.end; + } + if (cursor < markdown.length) { + segments.push({ kind: "markdown", markdown: markdown.slice(cursor), sourceOffset: cursor }); + } + return segments; +} + +export function artifactTemplateFromHastProperties( + properties: Readonly> | null | undefined, +): CodexArtifactTemplate | null { + if (properties?.dataCodexArtifactTemplate !== "true") return null; + const stringProperty = (name: string) => { + const value = properties[name]; + return typeof value === "string" ? value : undefined; + }; + return resolveCodexArtifactTemplate({ + artifact_kind: stringProperty("dataArtifactKind"), + display_name: stringProperty("dataDisplayName"), + gallery_kind: stringProperty("dataGalleryKind"), + skill_directory: stringProperty("dataSkillDirectory"), + skill_name: stringProperty("dataSkillName"), + }); +} diff --git a/packages/client-runtime/src/connection/catalog.ts b/packages/client-runtime/src/connection/catalog.ts index 8df3b2541d18..a79307947c5e 100644 --- a/packages/client-runtime/src/connection/catalog.ts +++ b/packages/client-runtime/src/connection/catalog.ts @@ -104,12 +104,6 @@ export const PlatformConnectionRegistration = Schema.Union([ ]); export type PlatformConnectionRegistration = typeof PlatformConnectionRegistration.Type; -export function connectionRegistrationTarget( - registration: ConnectionRegistration | PrimaryConnectionRegistration, -): ConnectionTarget { - return registration.target; -} - export function connectionRegistrationCatalogEntry( registration: ConnectionRegistration | PrimaryConnectionRegistration, ): ConnectionCatalogEntry { diff --git a/packages/client-runtime/src/connection/errors.test.ts b/packages/client-runtime/src/connection/errors.test.ts new file mode 100644 index 000000000000..771c9490ca3e --- /dev/null +++ b/packages/client-runtime/src/connection/errors.test.ts @@ -0,0 +1,75 @@ +import { EnvironmentAuthInvalidError } from "@t3tools/contracts"; +import { RelayAuthInvalidError } from "@t3tools/contracts/relay"; +import { describe, expect, it } from "@effect/vitest"; + +import { mapManagedRelayError, mapRemoteDpopEnvironmentError } from "./errors.ts"; +import { DPOP_RETRY_HINT, DPOP_UNKNOWN_HINT } from "../relay/errorPresentation.ts"; +import { ManagedRelayRequestFailedError } from "../relay/managedRelay.ts"; + +describe("mapManagedRelayError", () => { + it("presents clock skew as one possible cause for a generic DPoP error", () => { + const mapped = mapManagedRelayError( + new ManagedRelayRequestFailedError({ + action: "connect relay environment", + cause: new Error("request failed"), + relayError: new RelayAuthInvalidError({ + code: "auth_invalid", + reason: "invalid_dpop", + traceId: "trace-1", + }), + traceId: "trace-1", + }), + ); + + expect(mapped).toMatchObject({ + _tag: "ConnectionBlockedError", + reason: "authentication", + detail: `Relay rejected the DPoP proof. ${DPOP_UNKNOWN_HINT}`, + traceId: "trace-1", + }); + }); + + it("uses a neutral hint when the relay identifies a non-clock DPoP error", () => { + const mapped = mapManagedRelayError( + new ManagedRelayRequestFailedError({ + action: "connect relay environment", + cause: new Error("request failed"), + relayError: new RelayAuthInvalidError({ + code: "auth_invalid", + reason: "invalid_dpop", + dpopFailureReason: "key_mismatch", + traceId: "trace-1", + }), + }), + ); + + expect(mapped.message).toBe(`Relay rejected the DPoP proof. ${DPOP_RETRY_HINT}`); + }); +}); + +describe("mapRemoteDpopEnvironmentError", () => { + it("does not present a generic environment auth error as confirmed clock skew", () => { + const mapped = mapRemoteDpopEnvironmentError( + new EnvironmentAuthInvalidError({ + code: "auth_invalid", + reason: "invalid_credential", + traceId: "trace-1", + }), + ); + + expect(mapped.message).toBe(`The environment credential is invalid. ${DPOP_UNKNOWN_HINT}`); + }); + + it("uses a neutral hint for a non-clock DPoP error from a new server", () => { + const mapped = mapRemoteDpopEnvironmentError( + new EnvironmentAuthInvalidError({ + code: "auth_invalid", + reason: "invalid_credential", + dpopFailureReason: "key_mismatch", + traceId: "trace-1", + }), + ); + + expect(mapped.message).toBe(`The environment credential is invalid. ${DPOP_RETRY_HINT}`); + }); +}); diff --git a/packages/client-runtime/src/connection/errors.ts b/packages/client-runtime/src/connection/errors.ts index 66c10333d6a9..ed8a117a6f5c 100644 --- a/packages/client-runtime/src/connection/errors.ts +++ b/packages/client-runtime/src/connection/errors.ts @@ -1,6 +1,7 @@ import type { EnvironmentId } from "@t3tools/contracts"; import type { RelayProtectedError } from "@t3tools/contracts/relay"; import type { ManagedRelayClientError } from "../relay/managedRelay.ts"; +import { dpopFailureMessage, relayProtectedErrorMessage } from "../relay/errorPresentation.ts"; import type { RemoteEnvironmentAuthError } from "../authorization/remote.ts"; import { ConnectionBlockedError, @@ -40,7 +41,7 @@ function relayProtectedError(error: RelayProtectedError): ConnectionAttemptError case "RelayAgentActivityPublishProofInvalidError": return new ConnectionBlockedError({ reason: "authentication", - detail: error.message, + detail: relayProtectedErrorMessage(error), traceId: error.traceId, }); case "RelayEnvironmentConnectNotAuthorizedError": @@ -48,27 +49,27 @@ function relayProtectedError(error: RelayProtectedError): ConnectionAttemptError case "RelayEnvironmentLinkLimitExceededError": return new ConnectionBlockedError({ reason: "permission", - detail: error.message, + detail: relayProtectedErrorMessage(error), traceId: error.traceId, }); case "RelayEnvironmentEndpointTimedOutError": return new ConnectionTransientError({ reason: "timeout", - detail: error.message, + detail: relayProtectedErrorMessage(error), traceId: error.traceId, }); case "RelayEnvironmentEndpointUnavailableError": case "RelayEnvironmentLinkUnavailableError": return new ConnectionTransientError({ reason: "endpoint-unavailable", - detail: error.message, + detail: relayProtectedErrorMessage(error), traceId: error.traceId, }); case "RelayEnvironmentLinkFailedError": case "RelayInternalError": return new ConnectionTransientError({ reason: "relay-unavailable", - detail: error.message, + detail: relayProtectedErrorMessage(error), traceId: error.traceId, }); } @@ -166,3 +167,23 @@ export function mapRemoteEnvironmentError( }); } } + +/** + * Map an environment error from a request that used DPoP authentication. An + * older environment server reports a DPoP clock failure as the same generic + * invalid-credential response as other failures, so keep the compatibility + * hint cautious when the server omits the category. Newer servers can identify + * clock and non-clock proof failures precisely. + */ +export function mapRemoteDpopEnvironmentError( + error: RemoteEnvironmentAuthError, +): ConnectionAttemptError { + if (error._tag === "EnvironmentAuthInvalidError" && error.reason === "invalid_credential") { + return new ConnectionBlockedError({ + reason: "authentication", + detail: dpopFailureMessage("The environment credential is invalid.", error.dpopFailureReason), + traceId: error.traceId, + }); + } + return mapRemoteEnvironmentError(error); +} diff --git a/packages/client-runtime/src/connection/layer.ts b/packages/client-runtime/src/connection/layer.ts index 798ec01e2f0d..7927151e5d42 100644 --- a/packages/client-runtime/src/connection/layer.ts +++ b/packages/client-runtime/src/connection/layer.ts @@ -15,30 +15,29 @@ const resolverLayer = ConnectionResolver.layer.pipe( Layer.provide(RemoteEnvironmentAuthorization.layer), ); -const driverLayer = ConnectionDriver.layer.pipe( - Layer.provide(Layer.mergeAll(resolverLayer, RpcSession.layer)), -); - -const registryLayer = EnvironmentRegistry.layer.pipe(Layer.provide(driverLayer)); - -const onboardingLayer = ConnectionOnboarding.layer.pipe(Layer.provide(registryLayer)); - -const connectionServicesLayer = Layer.mergeAll( - registryLayer, - RelayEnvironmentDiscovery.layer, - onboardingLayer, -); - -const connectionStartupLayer = Layer.effectDiscard( - Effect.gen(function* () { - const registry = yield* EnvironmentRegistry.EnvironmentRegistry; - const platformSource = yield* PlatformConnectionSource.PlatformConnectionSource; - yield* registry.start; - yield* platformSource.registrations.pipe( - Stream.runForEach(registry.reconcilePlatform), - Effect.forkScoped, - ); - }).pipe(Effect.withSpan("clientRuntime.connection.application.start")), -); - -export const layer = connectionStartupLayer.pipe(Layer.provideMerge(connectionServicesLayer)); +export function layerWithOptions(options: RpcSession.RpcSessionOptions) { + const driverLayer = ConnectionDriver.layer.pipe( + Layer.provide(Layer.mergeAll(resolverLayer, RpcSession.layerWithOptions(options))), + ); + const registryLayer = EnvironmentRegistry.layer.pipe(Layer.provide(driverLayer)); + const onboardingLayer = ConnectionOnboarding.layer.pipe(Layer.provide(registryLayer)); + const connectionServicesLayer = Layer.mergeAll( + registryLayer, + RelayEnvironmentDiscovery.layer, + onboardingLayer, + ); + const connectionStartupLayer = Layer.effectDiscard( + Effect.gen(function* () { + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; + const platformSource = yield* PlatformConnectionSource.PlatformConnectionSource; + yield* registry.start; + yield* platformSource.registrations.pipe( + Stream.runForEach(registry.reconcilePlatform), + Effect.forkScoped, + ); + }).pipe(Effect.withSpan("clientRuntime.connection.application.start")), + ); + return connectionStartupLayer.pipe(Layer.provideMerge(connectionServicesLayer)); +} + +export const layer = layerWithOptions({}); diff --git a/packages/client-runtime/src/connection/registry.test.ts b/packages/client-runtime/src/connection/registry.test.ts index 9354db9c998a..4dbbfe45fe00 100644 --- a/packages/client-runtime/src/connection/registry.test.ts +++ b/packages/client-runtime/src/connection/registry.test.ts @@ -4,13 +4,17 @@ import { type OrchestrationShellSnapshot, } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; +import * as Context from "effect/Context"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; import * as Result from "effect/Result"; +import * as Scheduler from "effect/Scheduler"; +import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import * as SubscriptionRef from "effect/SubscriptionRef"; @@ -352,6 +356,8 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* ( Effect.succeed({ client: {} as RpcSession.RpcSession["client"], initialConfig: Effect.die(new Error("Config is not used by registry tests.")), + subscribeServerConfig: () => + Stream.die(new Error("Config is not used by registry tests.")), ready: Effect.void, probe: Effect.void, closed: Deferred.await(closed), @@ -420,6 +426,28 @@ function awaitConnectionState( } describe("EnvironmentRegistry", () => { + it.effect("does not acquire a session after the registry scope has already closed", () => + Effect.gen(function* () { + const harness = yield* makeHarness([TARGET]); + const registryScope = yield* Scope.make(); + const context = yield* Layer.build(harness.layer).pipe(Scope.provide(registryScope)); + const registry = Context.get(context, EnvironmentRegistry.EnvironmentRegistry); + const dispatcher = new Scheduler.MixedScheduler("sync", () => () => {}).makeDispatcher(); + const scheduler: Scheduler.Scheduler = { + executionMode: "sync", + shouldYield: () => false, + makeDispatcher: () => dispatcher, + }; + + yield* Scope.close(registryScope, Exit.void); + yield* registry.start.pipe(Effect.provideService(Scheduler.Scheduler, scheduler)); + dispatcher.flush(); + + expect(yield* Ref.get(harness.sessions)).toHaveLength(0); + expect(yield* Ref.get(harness.releasedSessions)).toBe(0); + }), + ); + it.effect("hydrates connection profiles into catalog entries", () => Effect.gen(function* () { const harness = yield* makeHarness([SSH_CONNECTION], [SSH_PROFILE]); diff --git a/packages/client-runtime/src/connection/registry.ts b/packages/client-runtime/src/connection/registry.ts index a3a36272f326..6907c43d6037 100644 --- a/packages/client-runtime/src/connection/registry.ts +++ b/packages/client-runtime/src/connection/registry.ts @@ -126,6 +126,7 @@ interface EnvironmentServiceScope { } export const make = Effect.gen(function* () { + const registryScope = yield* Scope.Scope; const storage = yield* Persistence.ConnectionTargetStore; const registrations = yield* Persistence.ConnectionRegistrationStore; const cache = yield* Persistence.EnvironmentCacheStore; @@ -249,7 +250,7 @@ export const make = Effect.gen(function* () { Effect.uninterruptible( Effect.gen(function* () { const environmentId = entry.target.environmentId; - const scope = yield* Scope.make(); + const scope = yield* Scope.fork(registryScope); const supervisor = yield* EnvironmentSupervisor.make(entry, { initiallyDesired: false, }).pipe( diff --git a/packages/client-runtime/src/connection/resolver.test.ts b/packages/client-runtime/src/connection/resolver.test.ts index d71c0f9602bf..68f4d11030b5 100644 --- a/packages/client-runtime/src/connection/resolver.test.ts +++ b/packages/client-runtime/src/connection/resolver.test.ts @@ -223,7 +223,8 @@ describe("ConnectionResolver", () => { environmentId: ENVIRONMENT_ID, label: "Primary", httpBaseUrl: "http://127.0.0.1:3777", - socketUrl: "ws://127.0.0.1:3777/ws?clientSurface=web", + socketUrl: + "ws://127.0.0.1:3777/ws?clientSurface=web&clientDeviceType=desktop&connectionMethod=direct", httpAuthorization: null, target, }); @@ -232,11 +233,14 @@ describe("ConnectionResolver", () => { it.effect("authorizes a desktop primary environment with its platform bearer token", () => Effect.gen(function* () { - const bearerInputs = yield* Ref.make>([]); + const bearerInputs = yield* Ref.make>([]); const brokerLayer = yield* makeDependencies({ primaryBearerToken: "desktop-bearer", authorizeBearer: (input) => - Ref.update(bearerInputs, (values) => [...values, input.bearerToken]).pipe( + Ref.update(bearerInputs, (values) => [ + ...values, + { token: input.bearerToken, method: input.connectionMethod }, + ]).pipe( Effect.as({ environmentId: input.expectedEnvironmentId, label: "Primary", @@ -262,13 +266,13 @@ describe("ConnectionResolver", () => { httpAuthorization: { _tag: "Bearer", token: "desktop-bearer" }, target, }); - expect(yield* Ref.get(bearerInputs)).toEqual(["desktop-bearer"]); + expect(yield* Ref.get(bearerInputs)).toEqual([{ token: "desktop-bearer", method: "direct" }]); }), ); it.effect("uses the registered bearer profile without re-reading the profile store", () => Effect.gen(function* () { - const bearerInputs = yield* Ref.make>([]); + const bearerInputs = yield* Ref.make>([]); const target = new BearerConnectionTarget({ environmentId: ENVIRONMENT_ID, label: "Saved", @@ -284,7 +288,10 @@ describe("ConnectionResolver", () => { const brokerLayer = yield* makeDependencies({ credentials: [["saved-1", new BearerConnectionCredential({ token: "secret-bearer" })]], authorizeBearer: (input) => - Ref.update(bearerInputs, (values) => [...values, input.bearerToken]).pipe( + Ref.update(bearerInputs, (values) => [ + ...values, + { token: input.bearerToken, method: input.connectionMethod }, + ]).pipe( Effect.as({ environmentId: input.expectedEnvironmentId, label: "Saved", @@ -302,7 +309,7 @@ describe("ConnectionResolver", () => { expect( (yield* broker.prepare(catalogEntry(target, Option.some(profile)))).socketUrl, ).toContain("wsTicket=ticket"); - expect(yield* Ref.get(bearerInputs)).toEqual(["secret-bearer"]); + expect(yield* Ref.get(bearerInputs)).toEqual([{ token: "secret-bearer", method: "direct" }]); }), ); @@ -411,6 +418,7 @@ describe("ConnectionResolver", () => { it.effect("delegates SSH launch to the platform gateway before remote authorization", () => Effect.gen(function* () { const preparedTargets = yield* Ref.make>([]); + const connectionMethods = yield* Ref.make>([]); const target = new SshConnectionTarget({ environmentId: ENVIRONMENT_ID, label: "SSH", @@ -435,6 +443,19 @@ describe("ConnectionResolver", () => { bearerToken: "ssh-bearer", }), ), + authorizeBearer: (input) => + Ref.update(connectionMethods, (methods) => [...methods, input.connectionMethod]).pipe( + Effect.as({ + environmentId: input.expectedEnvironmentId, + label: "SSH", + httpBaseUrl: input.httpBaseUrl, + socketUrl: "wss://environment.example.test/ws?wsTicket=bearer", + httpAuthorization: { + _tag: "Bearer" as const, + token: input.bearerToken, + }, + }), + ), }); const broker = yield* ConnectionResolver.ConnectionResolver.pipe(Effect.provide(brokerLayer)); @@ -442,6 +463,7 @@ describe("ConnectionResolver", () => { (yield* broker.prepare(catalogEntry(target, Option.some(profile)))).socketUrl, ).toContain("wsTicket=bearer"); expect(yield* Ref.get(preparedTargets)).toEqual([SSH_TARGET]); + expect(yield* Ref.get(connectionMethods)).toEqual(["ssh"]); }), ); diff --git a/packages/client-runtime/src/connection/resolver.ts b/packages/client-runtime/src/connection/resolver.ts index 3a5d5437a4e6..a786ed9f9d8d 100644 --- a/packages/client-runtime/src/connection/resolver.ts +++ b/packages/client-runtime/src/connection/resolver.ts @@ -56,7 +56,7 @@ function primarySocketUrl( if (url.pathname === "" || url.pathname === "/") { url.pathname = "/ws"; } - appendClientConnectionParams(url, clientMetadata); + appendClientConnectionParams(url, clientMetadata, "direct"); return url.toString(); } @@ -85,6 +85,7 @@ const makePrimaryBroker = Effect.fn("clientRuntime.connection.broker.makePrimary httpBaseUrl: target.httpBaseUrl, wsBaseUrl: target.wsBaseUrl, bearerToken: bearerToken.value, + connectionMethod: "direct", }); return { ...authorized, @@ -133,6 +134,7 @@ const makeBearerBroker = Effect.fn("clientRuntime.connection.broker.makeBearer") httpBaseUrl: profile.httpBaseUrl, wsBaseUrl: profile.wsBaseUrl, bearerToken: credential.token, + connectionMethod: "direct", }); return { environmentId: authorized.environmentId, @@ -236,6 +238,7 @@ const makeSshBroker = Effect.fn("clientRuntime.connection.broker.makeSsh")(funct httpBaseUrl: prepared.bootstrap.httpBaseUrl, wsBaseUrl: prepared.bootstrap.wsBaseUrl, bearerToken: prepared.bearerToken, + connectionMethod: "ssh", }); return { environmentId: authorized.environmentId, diff --git a/packages/client-runtime/src/connection/supervisor.test.ts b/packages/client-runtime/src/connection/supervisor.test.ts index 5e50c44d9610..d9f54bb326ca 100644 --- a/packages/client-runtime/src/connection/supervisor.test.ts +++ b/packages/client-runtime/src/connection/supervisor.test.ts @@ -163,6 +163,7 @@ const makeHarness = Effect.fn("TestConnectionHarness.make")(function* (options?: Effect.succeed({ client: TEST_RPC_CLIENT, initialConfig: Effect.die(new Error("Initial config is not used by supervisor tests.")), + subscribeServerConfig: (input) => TEST_RPC_CLIENT.subscribeServerConfig(input), ready: options?.ready?.(attempt) ?? Effect.void, probe: options?.probe?.(attempt) ?? Effect.void, closed: Deferred.await(closed), diff --git a/packages/client-runtime/src/markdownImages.test.ts b/packages/client-runtime/src/markdownImages.test.ts index a4160c3da4c1..8ee186756234 100644 --- a/packages/client-runtime/src/markdownImages.test.ts +++ b/packages/client-runtime/src/markdownImages.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; -import { classifyMarkdownImageSource } from "./markdownImages.js"; +import { classifyMarkdownImageSource, markdownImageSourceFragment } from "./markdownImages.js"; describe("classifyMarkdownImageSource", () => { it.each([ @@ -60,3 +60,12 @@ describe("classifyMarkdownImageSource", () => { expect(classifyMarkdownImageSource(source)).toEqual({ _tag: "Blocked" }); }); }); + +describe("markdownImageSourceFragment", () => { + it.each([ + ["", "#logo"], + ["icons.svg?version=2", ""], + ])("extracts %s as %s", (source, fragment) => { + expect(markdownImageSourceFragment(source)).toBe(fragment); + }); +}); diff --git a/packages/client-runtime/src/markdownImages.ts b/packages/client-runtime/src/markdownImages.ts index 404f828390ac..6f6fa31dbffa 100644 --- a/packages/client-runtime/src/markdownImages.ts +++ b/packages/client-runtime/src/markdownImages.ts @@ -20,6 +20,12 @@ function normalizeSource(value: string): string { return trimmed.startsWith("<") && trimmed.endsWith(">") ? trimmed.slice(1, -1) : trimmed; } +export function markdownImageSourceFragment(source: string): string { + const normalizedSource = normalizeSource(source); + const hashIndex = normalizedSource.indexOf("#"); + return hashIndex >= 0 ? normalizedSource.slice(hashIndex) : ""; +} + function normalizeWindowsDrivePath(value: string): string { return /^\/[A-Za-z]:[\\/]/.test(value) ? value.slice(1) : value; } @@ -61,7 +67,7 @@ function joinWorkspacePath(workspaceRoot: string, relativePath: string): string } /** - * Classifies a markdown image source by where its bytes must be loaded from. + * Classifies a markdown image or video source by where its bytes must be loaded from. * Filesystem paths belong to the environment host and must never reach a * browser or native image component without first becoming a signed asset URL. */ diff --git a/packages/client-runtime/src/markdownLinks.test.ts b/packages/client-runtime/src/markdownLinks.test.ts new file mode 100644 index 000000000000..5763fc5b6ff4 --- /dev/null +++ b/packages/client-runtime/src/markdownLinks.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { inlineCodeFilePathCandidate, isConventionalFilePosition } from "./markdownLinks.js"; + +describe("inlineCodeFilePathCandidate", () => { + it.each([ + ["src\\main.ts", "src/main.ts"], + ["C:\\Users\\demo\\image.png", "C:\\Users\\demo\\image.png"], + ["\\\\server\\share\\image.png", "\\\\server\\share\\image.png"], + ["conf.d/nginx.conf", "conf.d/nginx.conf"], + ["script.pl:10", "script.pl:10"], + ["node.meta", null], + ["Recorded evidence here: /tmp/image.png", null], + ["origin/main", null], + ["127.0.0.1:3000", null], + ["example.com/index.html", null], + ["example.pl/index.html", null], + ])("distinguishes file paths from code and hostnames in %s", (source, candidate) => { + expect(inlineCodeFilePathCandidate(source)).toBe(candidate); + }); +}); + +describe("isConventionalFilePosition", () => { + it("distinguishes extensionless file locations from labels and ports", () => { + expect(isConventionalFilePosition("Dockerfile:8:2")).toBe(true); + expect(isConventionalFilePosition("Makefile")).toBe(false); + expect(isConventionalFilePosition("TODO:12")).toBe(false); + expect(isConventionalFilePosition("port:3000")).toBe(false); + }); +}); diff --git a/packages/client-runtime/src/markdownLinks.ts b/packages/client-runtime/src/markdownLinks.ts new file mode 100644 index 000000000000..73048357e616 --- /dev/null +++ b/packages/client-runtime/src/markdownLinks.ts @@ -0,0 +1,158 @@ +const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:[\\/]/; +const WINDOWS_UNC_PATH_PATTERN = /^\\\\/; +const RELATIVE_PATH_PREFIX_PATTERN = /^(~\/|\.{1,2}\/)/; +const POSITION_SUFFIX_PATTERN = /:\d+(?::\d+)?$/; +const INLINE_CODE_DISQUALIFIER_PATTERN = /[\s`]/; +const PATH_SEPARATOR_PATTERN = /[\\/]/; +const FILE_EXTENSION_PATTERN = /\.[A-Za-z0-9_-]+$/; +const NUMERIC_DOTTED_PATTERN = /^\d+(?:\.\d+)+$/; +const BARE_EXTENSIONLESS_POSITION_PATTERN = /^[A-Za-z0-9_-]+(?::\d+){1,2}$/; +// `Name:digits` also matches `error:1`, `port:3000`, and `TODO:12`. +const EXTENSIONLESS_FILE_NAMES = new Set([ + "Makefile", + "makefile", + "GNUmakefile", + "Dockerfile", + "Containerfile", + "Justfile", + "justfile", + "Rakefile", + "Gemfile", + "Procfile", + "Brewfile", + "Caddyfile", + "Vagrantfile", + "Jenkinsfile", + "Podfile", + "Fastfile", + "BUILD", + "WORKSPACE", + "LICENSE", + "LICENCE", + "COPYING", + "NOTICE", + "AUTHORS", + "CONTRIBUTORS", + "CHANGELOG", + "README", + "CODEOWNERS", +]); +const SINGLE_LABEL_HOSTNAMES = new Set(["localhost"]); +// These allowlists avoid classifying dotted directories such as `conf.d/` +// or filenames such as `Makefile.in:12` as hosts. +const GENERIC_HOSTNAME_TLDS = new Set([ + "com", + "net", + "org", + "io", + "dev", + "app", + "ai", + "co", + "edu", + "gov", + "mil", + "info", + "biz", + "xyz", + "me", + "tv", + "cc", + "gg", + "chat", + "cloud", + "site", + "online", + "tech", + "store", + "link", +]); +// Country codes also name file extensions. A :line suffix makes `.pl` +// and `.pt` files more likely than hostnames. +const COUNTRY_HOSTNAME_TLDS = new Set([ + "uk", + "de", + "fr", + "nl", + "se", + "no", + "fi", + "dk", + "pl", + "ch", + "at", + "be", + "es", + "it", + "pt", + "eu", + "us", + "ca", + "au", + "nz", + "jp", + "kr", + "cn", + "br", + "ru", + "mx", + "ie", + "cz", + "tr", + "sg", + "hk", +]); + +function looksLikeHostname(segment: string, hasPosition: boolean): boolean { + if (segment.startsWith(".")) return false; + const lowered = segment.toLowerCase(); + if (SINGLE_LABEL_HOSTNAMES.has(lowered)) return true; + if (NUMERIC_DOTTED_PATTERN.test(segment)) return true; + const labels = lowered.split("."); + const lastLabel = labels.at(-1); + if (labels.length < 2 || lastLabel === undefined) return false; + if (GENERIC_HOSTNAME_TLDS.has(lastLabel)) return true; + return !hasPosition && COUNTRY_HOSTNAME_TLDS.has(lastLabel); +} + +/** Recognizes conventional extensionless filenames with an explicit line position. */ +export function isConventionalFilePosition(path: string): boolean { + return ( + BARE_EXTENSIONLESS_POSITION_PATTERN.test(path) && + EXTENSIONLESS_FILE_NAMES.has(path.replace(POSITION_SUFFIX_PATTERN, "")) + ); +} + +/** + * Picks path-shaped inline code for the client's markdown file-link resolver. + * It does not resolve paths or turn plain prose and fenced code into links. + */ +export function inlineCodeFilePathCandidate(codeText: string): string | null { + const trimmed = codeText.trim(); + if (trimmed.length === 0 || INLINE_CODE_DISQUALIFIER_PATTERN.test(trimmed)) return null; + + const candidate = + WINDOWS_DRIVE_PATH_PATTERN.test(trimmed) || WINDOWS_UNC_PATH_PATTERN.test(trimmed) + ? trimmed + : trimmed.replaceAll("\\", "/"); + const hasPosition = POSITION_SUFFIX_PATTERN.test(candidate); + if (!hasPosition && !PATH_SEPARATOR_PATTERN.test(candidate)) return null; + + const hasExplicitPathShape = + RELATIVE_PATH_PREFIX_PATTERN.test(candidate) || + candidate.startsWith("/") || + WINDOWS_DRIVE_PATH_PATTERN.test(candidate) || + WINDOWS_UNC_PATH_PATTERN.test(candidate); + if (!hasExplicitPathShape) { + const withoutPosition = candidate.replace(POSITION_SUFFIX_PATTERN, ""); + const firstSegment = withoutPosition.split("/")[0] ?? withoutPosition; + if (looksLikeHostname(firstSegment, hasPosition)) return null; + const basename = + withoutPosition + .replace(/[/\\]+$/, "") + .split(/[\\/]/) + .at(-1) ?? ""; + if (!hasPosition && !FILE_EXTENSION_PATTERN.test(basename)) return null; + } + return candidate; +} diff --git a/packages/client-runtime/src/mediaReference.test.ts b/packages/client-runtime/src/mediaReference.test.ts new file mode 100644 index 000000000000..236953ad8b15 --- /dev/null +++ b/packages/client-runtime/src/mediaReference.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { mediaFileReference, mediaReferenceFileName } from "./mediaReference.ts"; + +describe("mediaFileReference", () => { + it.each([ + ["/work/project/./media/../clip.mp4", "/work/project/", "clip.mp4"], + ["/work/project/../outside.mp4", "/work/project", undefined], + ["/work/project-other/clip.mp4", "/work/project", undefined], + ["/work/Project/clip.mp4", "/work/project", undefined], + ["/work/project/a\\b.mp4", "/work/project", "a\\b.mp4"], + ["/work/clip.mp4", "/work/other/..", "clip.mp4"], + ["/clip.mp4", "/", "clip.mp4"], + ["C:\\WORK\\project\\media\\..\\Clip.mp4", "c:/work/project/", "Clip.mp4"], + ["D:\\work\\clip.mp4", "C:\\work", undefined], + ["\\\\Server\\Share\\Project\\Clip.mp4", "//server/share/project", "Clip.mp4"], + ["\\\\server\\other\\clip.mp4", "\\\\server\\share", undefined], + ["../clip.mp4", "/work/project", undefined], + ])("preserves %s and only labels paths inside %s as relative", (path, root, relativePath) => { + expect(mediaFileReference(path, root)).toEqual({ + kind: "file", + path, + ...(relativePath === undefined ? {} : { relativePath }), + }); + }); +}); + +describe("mediaReferenceFileName", () => { + it.each([ + [{ kind: "file", path: "/tmp/take\\one%20.mp4" }, "take\\one%20.mp4"], + [{ kind: "file", path: "C:\\clips/take\\one.mp4" }, "one.mp4"], + [{ kind: "file", path: "\\\\server\\share\\one.mp4" }, "one.mp4"], + [{ kind: "url", url: "//cdn.example/clip%20one%2Emp4?sig=a+b#t=2" }, "clip one.mp4"], + [{ kind: "url", url: "https://cdn.example/clip%2520.mp4" }, "clip%20.mp4"], + [{ kind: "url", url: "https://cdn.example/clip%20%oops.mp4" }, "clip%20%oops.mp4"], + ] as const)("preserves filename semantics for %j", (reference, name) => { + expect(mediaReferenceFileName(reference)).toBe(name); + }); +}); diff --git a/packages/client-runtime/src/mediaReference.ts b/packages/client-runtime/src/mediaReference.ts new file mode 100644 index 000000000000..dcb93e6bc57e --- /dev/null +++ b/packages/client-runtime/src/mediaReference.ts @@ -0,0 +1,95 @@ +import { isWindowsAbsolutePath } from "@t3tools/shared/path"; + +/** The authored media location, never the temporary URL used to load its bytes. */ +export type MediaReference = + | { + readonly kind: "file"; + readonly path: string; + readonly relativePath?: string; + } + | { readonly kind: "url"; readonly url: string }; + +function absolutePathParts(path: string) { + const windows = isWindowsAbsolutePath(path) || path.startsWith("//"); + const normalized = windows ? path.replaceAll("\\", "/") : path; + const prefix = windows + ? /^(?:[a-z]:\/|\/\/[^/]+\/[^/]+(?:\/|$))/i.exec(normalized)?.[0] + : normalized.startsWith("/") + ? "/" + : undefined; + if (!prefix) return undefined; + + const segments: string[] = []; + for (const segment of normalized.slice(prefix.length).split("/")) { + if (!segment || segment === ".") continue; + if (segment === "..") segments.pop(); + else segments.push(segment); + } + const root = prefix.replace(/\/$/, ""); + return { root: windows ? root.toLowerCase() : root, segments, windows }; +} + +/** Compares paths lexically for the copy menu; it does not resolve filesystem symlinks. */ +export function mediaFileReference( + path: string, + workspaceRoot?: string | null, +): Extract { + const target = absolutePathParts(path); + const workspace = workspaceRoot ? absolutePathParts(workspaceRoot) : undefined; + if ( + !target || + !workspace || + target.windows !== workspace.windows || + target.root !== workspace.root || + target.segments.length <= workspace.segments.length || + !workspace.segments.every((segment, index) => + workspace.windows + ? segment.toLowerCase() === target.segments[index]?.toLowerCase() + : segment === target.segments[index], + ) + ) { + return { kind: "file", path }; + } + return { + kind: "file", + path, + relativePath: target.segments.slice(workspace.segments.length).join("/"), + }; +} + +/** Pass the authored source, not a generated URL used by the media player. */ +export function mediaUrlReference( + url: string, +): Extract | undefined { + if (!/^(?:https?:\/\/|\/\/)/i.test(url)) return undefined; + try { + const parsed = new URL(url.startsWith("//") ? `https:${url}` : url); + return parsed.protocol === "http:" || parsed.protocol === "https:" + ? { kind: "url", url } + : undefined; + } catch { + return undefined; + } +} + +/** Local paths are already decoded; URL filename escapes are decoded exactly once. */ +export function mediaReferenceFileName(reference: MediaReference): string | undefined { + if (reference.kind === "file") { + const windows = isWindowsAbsolutePath(reference.path) || reference.path.startsWith("//"); + return reference.path.split(windows ? /[\\/]/ : "/").at(-1) || undefined; + } + + let basename: string | undefined; + try { + const url = reference.url; + basename = new URL(url.startsWith("//") ? `https:${url}` : url).pathname.split("/").at(-1); + } catch { + return undefined; + } + if (!basename) return undefined; + try { + return decodeURIComponent(basename); + } catch { + return basename; + } +} diff --git a/packages/client-runtime/src/operations/commands.test.ts b/packages/client-runtime/src/operations/commands.test.ts index 0cb1650066c4..36bc6a7b296f 100644 --- a/packages/client-runtime/src/operations/commands.test.ts +++ b/packages/client-runtime/src/operations/commands.test.ts @@ -57,6 +57,7 @@ const makeSupervisor = Effect.fn("TestEnvironmentCommands.makeSupervisor")(funct const session: RpcSession.RpcSession = { client, initialConfig: Effect.never, + subscribeServerConfig: (input) => client.subscribeServerConfig(input), ready: Effect.void, probe: Effect.void, closed: Effect.never, diff --git a/packages/client-runtime/src/providerSkills.test.ts b/packages/client-runtime/src/providerSkills.test.ts index 08c79f5d8718..29dcf4ffc60b 100644 --- a/packages/client-runtime/src/providerSkills.test.ts +++ b/packages/client-runtime/src/providerSkills.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vite-plus/test"; import { + dedupeProviderSkillsByName, formatProviderSkillDisplayName, getProviderSlashCommandsForSlashMenu, getProviderSkillsForSlashMenu, @@ -26,6 +27,31 @@ describe("formatProviderSkillDisplayName", () => { }); }); +describe("dedupeProviderSkillsByName", () => { + it("keeps the first resolved skill and preserves unrelated skill order", () => { + const firstSkill = { + name: "branch-audit", + path: "/Users/matt/.codex/skills/branch-audit/SKILL.md", + enabled: true, + }; + const otherSkill = { + name: "browser", + path: "/Users/matt/.agents/skills/browser/SKILL.md", + enabled: true, + }; + const duplicateSkill = { + name: "Branch-Audit", + path: "/Users/matt/.agents/skills/branch-audit/SKILL.md", + enabled: true, + }; + + expect(dedupeProviderSkillsByName([firstSkill, otherSkill, duplicateSkill])).toEqual([ + firstSkill, + otherSkill, + ]); + }); +}); + describe("getProviderSkillsForSlashMenu", () => { it("keeps the skill alias when the provider also exposes it as a slash command", () => { const askMatt = { @@ -37,6 +63,73 @@ describe("getProviderSkillsForSlashMenu", () => { "ask-matt", ]); }); + + it("shows one row when enabled skills share a name", () => { + const skills = [ + { + name: "babysit-pr", + path: "/Users/matt/.codex/skills/babysit-pr/SKILL.md", + enabled: true, + }, + { + name: "browser", + path: "/Users/matt/.agents/skills/browser/SKILL.md", + enabled: true, + }, + { + name: "babysit-pr", + path: "/Users/matt/.agents/skills/babysit-pr/SKILL.md", + enabled: true, + }, + ]; + + expect(getProviderSkillsForSlashMenu(skills, true).map((skill) => skill.name)).toEqual([ + "babysit-pr", + "browser", + ]); + }); + + it("keeps an enabled skill when a disabled duplicate appears first", () => { + const enabledSkill = { + name: "babysit-pr", + path: "/Users/matt/.agents/skills/babysit-pr/SKILL.md", + enabled: true, + }; + const skills = [ + { + name: "babysit-pr", + path: "/Users/matt/.codex/skills/babysit-pr/SKILL.md", + enabled: false, + }, + enabledSkill, + ]; + + expect(getProviderSkillsForSlashMenu(skills, true)).toEqual([enabledSkill]); + }); +}); + +describe("getProviderSkillsForSlashMenu", () => { + it("drops a skill the provider reserves for the agent", () => { + const skills = [ + { + name: "legacy-system-context", + path: "/Users/matt/.claude/skills/legacy-system-context/SKILL.md", + enabled: true, + userInvocable: false, + }, + { + name: "deploy", + path: "/Users/matt/.claude/skills/deploy/SKILL.md", + enabled: true, + // Reserved for the user, not the agent: still a valid pick. + userInvocationOnly: true, + }, + ]; + + expect(getProviderSkillsForSlashMenu(skills, true).map((skill) => skill.name)).toEqual([ + "deploy", + ]); + }); }); describe("getProviderSlashCommandsForSlashMenu", () => { diff --git a/packages/client-runtime/src/providerSkills.ts b/packages/client-runtime/src/providerSkills.ts index faab12799ba9..f90ffe142e4a 100644 --- a/packages/client-runtime/src/providerSkills.ts +++ b/packages/client-runtime/src/providerSkills.ts @@ -25,11 +25,40 @@ export function formatProviderSkillDisplayName( return titleCaseWords(skill.name); } +export function dedupeProviderSkillsByName( + skills: ReadonlyArray, +): ServerProviderSkill[] { + const seenNames = new Set(); + return skills.filter((skill) => { + const normalizedName = skill.name.trim().toLowerCase(); + if (seenNames.has(normalizedName)) { + return false; + } + seenNames.add(normalizedName); + return true; + }); +} + +/** + * Whether a composer pick can start this skill. A skill switched off in the + * provider's settings will not run, and one the provider reserves for the + * agent (Claude Code's `user-invocable: false`) rejects a user invocation. + * Everything else, including skills the agent may not start on its own, is + * fair game: the server dispatches the pick in the provider's native form. + */ +export function isProviderSkillUserInvocable( + skill: Pick, +): boolean { + return skill.enabled && skill.userInvocable !== false; +} + export function getProviderSkillsForSlashMenu( skills: ReadonlyArray, showSkillsInSlashMenu: boolean, ): ServerProviderSkill[] { - return showSkillsInSlashMenu ? skills.filter((skill) => skill.enabled) : []; + return showSkillsInSlashMenu + ? dedupeProviderSkillsByName(skills.filter(isProviderSkillUserInvocable)) + : []; } export function getProviderSlashCommandsForSlashMenu( diff --git a/packages/client-runtime/src/relay/errorPresentation.test.ts b/packages/client-runtime/src/relay/errorPresentation.test.ts new file mode 100644 index 000000000000..a27810c4366e --- /dev/null +++ b/packages/client-runtime/src/relay/errorPresentation.test.ts @@ -0,0 +1,57 @@ +import { RelayAuthInvalidError } from "@t3tools/contracts/relay"; +import { describe, expect, it } from "@effect/vitest"; + +import { + DPOP_CLOCK_HINT, + DPOP_RETRY_HINT, + DPOP_UNKNOWN_HINT, + relayProtectedErrorMessage, +} from "./errorPresentation.ts"; + +describe("relayProtectedErrorMessage", () => { + it("presents clock skew as one possible cause when the relay omits the reason", () => { + const error = new RelayAuthInvalidError({ + code: "auth_invalid", + reason: "invalid_dpop", + traceId: "trace-1", + }); + + expect(relayProtectedErrorMessage(error)).toBe( + `Relay rejected the DPoP proof. ${DPOP_UNKNOWN_HINT}`, + ); + }); + + it("keeps the clock hint for a relay that confirms a time-window failure", () => { + const error = new RelayAuthInvalidError({ + code: "auth_invalid", + reason: "invalid_dpop", + dpopFailureReason: "time_window", + traceId: "trace-1", + }); + + expect(relayProtectedErrorMessage(error)).toContain(DPOP_CLOCK_HINT); + }); + + it("does not blame the clock when the relay identifies another proof failure", () => { + const error = new RelayAuthInvalidError({ + code: "auth_invalid", + reason: "invalid_dpop", + dpopFailureReason: "key_mismatch", + traceId: "trace-1", + }); + + expect(relayProtectedErrorMessage(error)).toBe( + `Relay rejected the DPoP proof. ${DPOP_RETRY_HINT}`, + ); + }); + + it("preserves the existing message for other authentication failures", () => { + const error = new RelayAuthInvalidError({ + code: "auth_invalid", + reason: "invalid_bearer", + traceId: "trace-1", + }); + + expect(relayProtectedErrorMessage(error)).toBe("Relay rejected the cloud session token."); + }); +}); diff --git a/packages/client-runtime/src/relay/errorPresentation.ts b/packages/client-runtime/src/relay/errorPresentation.ts new file mode 100644 index 000000000000..a9364752103d --- /dev/null +++ b/packages/client-runtime/src/relay/errorPresentation.ts @@ -0,0 +1,66 @@ +import type { DpopFailureReason } from "@t3tools/contracts"; +import type { RelayProtectedError } from "@t3tools/contracts/relay"; + +export const DPOP_CLOCK_HINT = + "Hint: Check that automatic date and time is enabled on both devices, then try again."; + +/** Older servers omit the DPoP category, but newer servers can also omit it for + * a credential failure that happens after proof verification. */ +export const DPOP_UNKNOWN_HINT = + "Hint: Try again. If it still fails, clock skew may be the cause; check that automatic date and time is enabled on both devices."; + +export const DPOP_RETRY_HINT = "Hint: Try again. If the problem continues, copy the trace ID."; + +export function dpopFailureHint(reason: DpopFailureReason | undefined): string { + if (reason === "time_window") return DPOP_CLOCK_HINT; + if (reason === undefined) return DPOP_UNKNOWN_HINT; + return DPOP_RETRY_HINT; +} + +export function dpopFailureMessage(message: string, reason: DpopFailureReason | undefined): string { + return `${message} ${dpopFailureHint(reason)}`; +} + +export function relayProtectedErrorMessage(error: RelayProtectedError): string { + switch (error._tag) { + case "RelayAuthInvalidError": + switch (error.reason) { + case "missing_bearer": + case "invalid_bearer": + return "Relay rejected the cloud session token."; + case "invalid_dpop": + return dpopFailureMessage("Relay rejected the DPoP proof.", error.dpopFailureReason); + case "not_authorized": + return "Relay rejected the authenticated request."; + } + case "RelayEnvironmentLinkProofExpiredError": + return "Relay rejected an expired environment link proof."; + case "RelayEnvironmentLinkProofInvalidError": + return `Relay rejected the environment link proof (${error.reason}).`; + case "RelayEnvironmentConnectNotAuthorizedError": + // "Not authorized" covers non-auth causes too; surface the reason so a + // missing link does not read as a credential problem. + if (error.reason === "environment_link_not_found") { + return "Relay has no active link for this environment. The environment server may not have re-established its link yet."; + } + return error.reason + ? `Relay rejected the environment connection request (${error.reason}).` + : "Relay rejected the environment connection request."; + case "RelayEnvironmentEndpointUnavailableError": + return `Relay could not reach the environment endpoint (${error.reason}).`; + case "RelayEnvironmentEndpointTimedOutError": + return "Relay timed out while contacting the environment endpoint."; + case "RelayEnvironmentLinkFailedError": + return `Relay could not link the environment (${error.reason}).`; + case "RelayEnvironmentLinkUnavailableError": + return `Relay cannot provision the managed endpoint (${error.reason}).`; + case "RelayEnvironmentLinkLimitExceededError": + return `Relay refused the link: this account already has its maximum of ${error.maxTunnels} managed tunnels. Unlink an environment to free one up.`; + case "RelayAgentActivityPublishProofExpiredError": + return "Relay rejected an expired agent activity publish proof."; + case "RelayAgentActivityPublishProofInvalidError": + return `Relay rejected the agent activity publish proof (${error.reason}).`; + case "RelayInternalError": + return `Relay encountered an internal error (${error.reason}).`; + } +} diff --git a/packages/client-runtime/src/relay/index.ts b/packages/client-runtime/src/relay/index.ts index 76f755353044..4c8104eb44bc 100644 --- a/packages/client-runtime/src/relay/index.ts +++ b/packages/client-runtime/src/relay/index.ts @@ -1,3 +1,4 @@ export * as Discovery from "./discovery.ts"; +export * from "./errorPresentation.ts"; export * as ManagedRelay from "./managedRelay.ts"; export * from "./managedRelayState.ts"; diff --git a/packages/client-runtime/src/relay/managedRelay.test.ts b/packages/client-runtime/src/relay/managedRelay.test.ts index 278c205883f5..fb2feaa61772 100644 --- a/packages/client-runtime/src/relay/managedRelay.test.ts +++ b/packages/client-runtime/src/relay/managedRelay.test.ts @@ -462,6 +462,43 @@ describe("ManagedRelayClient", () => { }).pipe(Effect.provide(managedRelayTestLayer(fetchFn))); }); + it.effect("accepts generic DPoP errors from relays without the optional reason", () => { + const fetchFn = (() => + Promise.resolve( + Response.json( + { + _tag: "RelayAuthInvalidError", + code: "auth_invalid", + reason: "invalid_dpop", + traceId: "trace-old-relay", + }, + { status: 401 }, + ), + )) satisfies typeof globalThis.fetch; + + return Effect.gen(function* () { + const relayClient = yield* ManagedRelay.ManagedRelayClient; + const error = yield* relayClient + .listEnvironments({ clerkToken: "clerk-token" }) + .pipe(Effect.flip); + + expect(error).toMatchObject({ + _tag: "ManagedRelayRequestFailedError", + traceId: "trace-old-relay", + relayError: { + _tag: "RelayAuthInvalidError", + reason: "invalid_dpop", + }, + }); + if ( + error._tag === "ManagedRelayRequestFailedError" && + error.relayError?._tag === "RelayAuthInvalidError" + ) { + expect(error.relayError.dpopFailureReason).toBeUndefined(); + } + }).pipe(Effect.provide(managedRelayTestLayer(fetchFn))); + }); + it.effect("lists account devices through the Clerk bearer client endpoint", () => { const fetchFn = ((input, init) => { expect(String(input)).toBe("https://relay.example.test/v1/client/devices"); diff --git a/packages/client-runtime/src/relay/managedRelayState.test.ts b/packages/client-runtime/src/relay/managedRelayState.test.ts index 00ac733762fe..0588da342066 100644 --- a/packages/client-runtime/src/relay/managedRelayState.test.ts +++ b/packages/client-runtime/src/relay/managedRelayState.test.ts @@ -1,8 +1,9 @@ import { EnvironmentId } from "@t3tools/contracts"; -import type { - RelayClientDeviceRecord, - RelayClientEnvironmentRecord, - RelayEnvironmentStatusResponse, +import { + RelayAuthInvalidError, + type RelayClientDeviceRecord, + type RelayClientEnvironmentRecord, + type RelayEnvironmentStatusResponse, } from "@t3tools/contracts/relay"; import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; @@ -12,6 +13,7 @@ import * as Stream from "effect/Stream"; import { Atom, AtomRegistry } from "effect/unstable/reactivity"; import { afterEach, vi } from "vite-plus/test"; +import { DPOP_UNKNOWN_HINT } from "./errorPresentation.ts"; import * as ManagedRelay from "./managedRelay.ts"; import { createManagedRelayQueryManager, @@ -421,4 +423,31 @@ describe("createManagedRelayQueryManager", () => { }); }); }); + + it("presents clock skew as one possible cause for snapshot requests from older relays", async () => { + const manager = createManager({ + getEnvironmentStatus: () => + Effect.fail( + new ManagedRelay.ManagedRelayRequestFailedError({ + action: "get relay environment status", + cause: new Error("Relay request failed."), + relayError: new RelayAuthInvalidError({ + code: "auth_invalid", + reason: "invalid_dpop", + traceId: "trace-status", + }), + traceId: "trace-status", + }), + ), + }); + setSession(); + const atom = manager.environmentStatusAtom({ accountId: "account-1", environment }); + + registry.get(atom); + await vi.waitFor(() => { + expect(readManagedRelaySnapshotState(registry.get(atom)).error).toBe( + `Relay rejected the DPoP proof. ${DPOP_UNKNOWN_HINT}`, + ); + }); + }); }); diff --git a/packages/client-runtime/src/relay/managedRelayState.ts b/packages/client-runtime/src/relay/managedRelayState.ts index 1a3a22efb204..6eb1fb6c6760 100644 --- a/packages/client-runtime/src/relay/managedRelayState.ts +++ b/packages/client-runtime/src/relay/managedRelayState.ts @@ -13,15 +13,18 @@ import * as Clock from "effect/Clock"; import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; import { findErrorTraceId } from "../errors/errorTrace.ts"; import * as ManagedRelay from "./managedRelay.ts"; +import { relayProtectedErrorMessage } from "./errorPresentation.ts"; const DEFAULT_STALE_TIME_MS = 15_000; const DEFAULT_IDLE_TTL_MS = 5 * 60_000; const CLERK_TOKEN_EXPIRY_SKEW_MS = 5_000; +const isManagedRelayRequestFailedError = Schema.is(ManagedRelay.ManagedRelayRequestFailedError); export interface ManagedRelaySession { readonly accountId: string; @@ -315,7 +318,12 @@ export function readManagedRelaySnapshotState( let errorTraceId: string | null = null; if (result._tag === "Failure") { const cause = Cause.squash(result.cause); - error = cause instanceof Error ? cause.message : "Could not load T3 Connect data."; + error = + isManagedRelayRequestFailedError(cause) && cause.relayError + ? relayProtectedErrorMessage(cause.relayError) + : cause instanceof Error + ? cause.message + : "Could not load T3 Connect data."; errorTraceId = findErrorTraceId(cause); } return { diff --git a/packages/client-runtime/src/rpc/client.test.ts b/packages/client-runtime/src/rpc/client.test.ts index 507d137caccb..4e6baba8bef4 100644 --- a/packages/client-runtime/src/rpc/client.test.ts +++ b/packages/client-runtime/src/rpc/client.test.ts @@ -1,6 +1,8 @@ import { + DEFAULT_SERVER_SETTINGS, EnvironmentId, type RelayClientInstallProgressEvent, + type ServerConfigStreamEvent, WS_METHODS, } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; @@ -47,6 +49,7 @@ function session(client: WsRpcProtocolClient): RpcSession.RpcSession { return { client, initialConfig: Effect.never, + subscribeServerConfig: (input) => client.subscribeServerConfig(input), ready: Effect.void, probe: Effect.void, closed: Effect.never, @@ -77,6 +80,39 @@ const makeHarness = Effect.fn("TestEnvironmentRpc.makeHarness")(function* () { }); describe("environment RPC", () => { + it.effect("reuses the session config stream instead of opening a duplicate subscription", () => + Effect.gen(function* () { + const event: ServerConfigStreamEvent = { + version: 1, + type: "settingsUpdated", + payload: { settings: DEFAULT_SERVER_SETTINGS }, + }; + let duplicateSubscriptions = 0; + const client = { + [WS_METHODS.subscribeServerConfig]: () => { + duplicateSubscriptions += 1; + return Stream.never; + }, + } as unknown as WsRpcProtocolClient; + const { activeSession, supervisor } = yield* makeHarness(); + yield* SubscriptionRef.set( + activeSession, + Option.some({ + ...session(client), + subscribeServerConfig: () => Stream.succeed(event), + }), + ); + + const received = yield* subscribe(WS_METHODS.subscribeServerConfig, {}).pipe( + Stream.runHead, + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + ); + + expect(received).toEqual(Option.some(event)); + expect(duplicateSubscriptions).toBe(0); + }), + ); + it.effect("observes unary requests until they complete", () => Effect.gen(function* () { const observations: string[] = []; diff --git a/packages/client-runtime/src/rpc/client.ts b/packages/client-runtime/src/rpc/client.ts index bfe57a6c0dd5..50cc029eccff 100644 --- a/packages/client-runtime/src/rpc/client.ts +++ b/packages/client-runtime/src/rpc/client.ts @@ -203,7 +203,11 @@ export function subscribeDynamic( Option.match({ onNone: () => Stream.empty, onSome: (session) => { - const method = session.client[tag] as ( + const method = ( + tag === WS_METHODS.subscribeServerConfig + ? session.subscribeServerConfig + : session.client[tag] + ) as ( input: EnvironmentRpcInput, ) => Stream.Stream< EnvironmentRpcStreamValue, diff --git a/packages/client-runtime/src/rpc/session.test.ts b/packages/client-runtime/src/rpc/session.test.ts index 0af5850bf6c7..aedd85c5de47 100644 --- a/packages/client-runtime/src/rpc/session.test.ts +++ b/packages/client-runtime/src/rpc/session.test.ts @@ -1,24 +1,41 @@ import { DEFAULT_SERVER_SETTINGS, EnvironmentId, + ProviderDriverKind, + ProviderInstanceId, ServerConfig, type ServerConfig as ServerConfigType, + ServerConfigStreamEvent, + type ServerConfigStreamEvent as ServerConfigStreamEventType, WS_METHODS, } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; +import * as Cause from "effect/Cause"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Queue from "effect/Queue"; import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; import * as TestClock from "effect/testing/TestClock"; import * as Socket from "effect/unstable/socket/Socket"; import { + AVAILABLE_CONNECTION_STATE, + ConnectionBlockedError, ConnectionTransientError, PrimaryConnectionTarget, type PreparedConnection, } from "../connection/model.ts"; +import * as EnvironmentSupervisor from "../connection/supervisor.ts"; +import * as Persistence from "../platform/persistence.ts"; import * as RpcSession from "./session.ts"; +import { makeEnvironmentServerConfigState } from "../state/server.ts"; +import { applyServerConfigProjection } from "../state/serverConfigProjection.ts"; type SocketEventType = "open" | "message" | "close" | "error"; type SocketEvent = { @@ -139,10 +156,24 @@ const RpcRequest = Schema.TaggedStruct("Request", { tag: Schema.String, }); const decodeJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); -const decodeRpcRequest = Schema.decodeUnknownSync(RpcRequest); +const isRpcRequest = Schema.is(RpcRequest); +const isPing = Schema.is(Schema.Struct({ _tag: Schema.Literal("Ping") })); const encodeJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); const encodeServerConfig = Schema.encodeSync(ServerConfig); +const encodeServerConfigStreamEvent = Schema.encodeSync(ServerConfigStreamEvent); +const encodeDefect = Schema.encodeSync(Schema.Defect()); const ENCODED_SERVER_CONFIG = encodeServerConfig(SERVER_CONFIG); +const THEME_SERVER_CONFIG: ServerConfigType = { + ...SERVER_CONFIG, + environment: { + ...SERVER_CONFIG.environment, + capabilities: { + ...SERVER_CONFIG.environment.capabilities, + environmentThemes: true, + }, + }, +}; +const ENCODED_THEME_SERVER_CONFIG = encodeServerConfig(THEME_SERVER_CONFIG); const LEGACY_SERVER_CONFIG = { ...ENCODED_SERVER_CONFIG, environment: { @@ -153,23 +184,26 @@ const LEGACY_SERVER_CONFIG = { }, }; -const makeFactory = Effect.fn("TestRpcSessionFactory.make")(function* () { +const makeFactory = Effect.fn("TestRpcSessionFactory.make")(function* ( + options: RpcSession.RpcSessionOptions = {}, +) { const sockets: TestWebSocket[] = []; const constructorLayer = Layer.succeed(Socket.WebSocketConstructor, (url) => { const socket = new TestWebSocket(url); sockets.push(socket); return socket as unknown as globalThis.WebSocket; }); - const layer = RpcSession.layer.pipe(Layer.provide(constructorLayer)); + const layer = RpcSession.layerWithOptions(options).pipe(Layer.provide(constructorLayer)); const factory = yield* RpcSession.RpcSessionFactory.pipe(Effect.provide(layer)); return { factory, sockets }; }); const awaitSocket = Effect.fn("TestRpcSessionFactory.awaitSocket")(function* ( sockets: ReadonlyArray, + index = 0, ) { for (let attempt = 0; attempt < 100; attempt += 1) { - const socket = sockets[0]; + const socket = sockets[index]; if (socket) { return socket; } @@ -183,9 +217,9 @@ const awaitRequest = Effect.fn("TestRpcSessionFactory.awaitRequest")(function* ( index = 0, ) { for (let attempt = 0; attempt < 100; attempt += 1) { - const request = socket.sent[index]; + const request = socket.sent.map((message) => decodeJson(message)).filter(isRpcRequest)[index]; if (request) { - return decodeRpcRequest(decodeJson(request)); + return request; } yield* Effect.yieldNow; } @@ -195,21 +229,33 @@ const awaitRequest = Effect.fn("TestRpcSessionFactory.awaitRequest")(function* ( const completeInitialConfig = Effect.fn("TestRpcSessionFactory.completeInitialConfig")(function* ( socket: TestWebSocket, config: unknown = ENCODED_SERVER_CONFIG, + payload: unknown = {}, ) { const request = yield* awaitRequest(socket); expect(request).toMatchObject({ _tag: "Request", - tag: WS_METHODS.serverGetConfig, - payload: {}, + tag: WS_METHODS.subscribeServerConfig, + payload, }); socket.serverMessage( encodeJson({ - _tag: "Exit", + _tag: "Chunk", requestId: request.id, - exit: { - _tag: "Success", - value: config, - }, + values: [{ version: 1, type: "snapshot", config }], + }), + ); +}); + +const publishConfigEvents = Effect.fn("TestRpcSessionFactory.publishConfigEvents")(function* ( + socket: TestWebSocket, + events: ReadonlyArray, +) { + const request = yield* awaitRequest(socket); + socket.serverMessage( + encodeJson({ + _tag: "Chunk", + requestId: request.id, + values: events.map((event) => encodeServerConfigStreamEvent(event)), }), ); }); @@ -229,7 +275,9 @@ describe("RpcSessionFactory", () => { const config = yield* session.initialConfig; expect(config).toEqual(SERVER_CONFIG); - expect(socket.sent).toHaveLength(1); + expect(socket.sent.map((message) => decodeJson(message)).filter(isRpcRequest)).toHaveLength( + 1, + ); const probeFiber = yield* Effect.forkChild(session.probe); const probeRequest = yield* awaitRequest(socket, 1); @@ -250,19 +298,25 @@ describe("RpcSessionFactory", () => { ); yield* Fiber.join(probeFiber); - expect(socket.sent.map((request) => decodeRpcRequest(decodeJson(request)).tag)).toEqual([ - WS_METHODS.serverGetConfig, - WS_METHODS.serverProbe, - ]); + expect( + socket.sent + .map((message) => decodeJson(message)) + .filter(isRpcRequest) + .map((request) => request.tag), + ).toEqual([WS_METHODS.subscribeServerConfig, WS_METHODS.serverProbe]); socket.close(1012, "service restart"); const error = yield* Effect.flip(session.closed); + const configStreamError = yield* session + .subscribeServerConfig({}) + .pipe(Stream.runDrain, Effect.flip); expect(error).toBeInstanceOf(ConnectionTransientError); expect(error).toMatchObject({ reason: "transport", message: "Test environment disconnected.", }); + expect(configStreamError).toMatchObject({ _tag: "RpcClientError" }); yield* Effect.yieldNow; expect(sockets).toHaveLength(1); }), @@ -287,6 +341,602 @@ describe("RpcSessionFactory", () => { }), ); + it.effect("replays current config and broadcasts updates to every subscriber", () => + Effect.scoped( + Effect.gen(function* () { + const { factory, sockets } = yield* makeFactory(); + const session = yield* factory.connect(PREPARED); + const readyFiber = yield* Effect.forkChild(session.ready); + const socket = yield* awaitSocket(sockets); + socket.open(); + yield* completeInitialConfig(socket); + yield* Fiber.join(readyFiber); + + const collectTwo = session + .subscribeServerConfig({}) + .pipe(Stream.take(2), Stream.runCollect); + const firstSubscriber = yield* Effect.forkChild(collectTwo); + const secondSubscriber = yield* Effect.forkChild(collectTwo); + yield* Effect.yieldNow; + + const shortcut = { + key: "k", + metaKey: false, + ctrlKey: false, + shiftKey: false, + altKey: false, + modKey: true, + }; + const request = yield* awaitRequest(socket); + socket.serverMessage( + encodeJson({ + _tag: "Chunk", + requestId: request.id, + values: [ + { + version: 1, + type: "keybindingsUpdated", + payload: { + keybindings: [{ command: "terminal.toggle", shortcut }], + issues: [], + }, + }, + ], + }), + ); + + const firstEvents = Array.from(yield* Fiber.join(firstSubscriber)); + const secondEvents = Array.from(yield* Fiber.join(secondSubscriber)); + expect(firstEvents.map((event) => event.type)).toEqual(["snapshot", "keybindingsUpdated"]); + expect(secondEvents).toEqual(firstEvents); + + const replay = yield* session.subscribeServerConfig({}).pipe(Stream.runHead); + expect(replay).toMatchObject({ + _tag: "Some", + value: { + type: "snapshot", + config: { keybindings: [{ command: "terminal.toggle", shortcut }] }, + }, + }); + }), + ), + ); + + it.effect("shares only a config subscription with the same theme opt-in", () => + Effect.scoped( + Effect.gen(function* () { + const { factory, sockets } = yield* makeFactory({ environmentThemes: true }); + const session = yield* factory.connect(PREPARED); + const readyFiber = yield* Effect.forkChild(session.ready); + const socket = yield* awaitSocket(sockets); + socket.open(); + yield* completeInitialConfig(socket, ENCODED_THEME_SERVER_CONFIG, { + environmentThemes: true, + }); + yield* Fiber.join(readyFiber); + + const shared = yield* session + .subscribeServerConfig({ environmentThemes: true }) + .pipe(Stream.runHead); + expect(shared).toMatchObject({ _tag: "Some", value: { type: "snapshot" } }); + expect(socket.sent.map((message) => decodeJson(message)).filter(isRpcRequest)).toHaveLength( + 1, + ); + + const fallbackFiber = yield* session + .subscribeServerConfig({}) + .pipe(Stream.runHead, Effect.forkChild); + const fallbackRequest = yield* awaitRequest(socket, 1); + expect(fallbackRequest).toMatchObject({ + tag: WS_METHODS.subscribeServerConfig, + payload: {}, + }); + socket.serverMessage( + encodeJson({ + _tag: "Chunk", + requestId: fallbackRequest.id, + values: [ + { + version: 1, + type: "snapshot", + config: ENCODED_THEME_SERVER_CONFIG, + }, + ], + }), + ); + expect(yield* Fiber.join(fallbackFiber)).toMatchObject({ + _tag: "Some", + value: { type: "snapshot" }, + }); + }), + ), + ); + + it.effect("replays theme updates and deletion as authoritative events", () => + Effect.scoped( + Effect.gen(function* () { + const { factory, sockets } = yield* makeFactory({ environmentThemes: true }); + const session = yield* factory.connect(PREPARED); + const readyFiber = yield* Effect.forkChild(session.ready); + const socket = yield* awaitSocket(sockets); + socket.open(); + yield* completeInitialConfig(socket, ENCODED_THEME_SERVER_CONFIG, { + environmentThemes: true, + }); + yield* Fiber.join(readyFiber); + + const firstThemes = [ + { + id: "nightfall", + name: "Nightfall", + appearance: "dark" as const, + canvas: "#1a1b26", + accent: "#7aa2f7", + }, + ]; + const replacementThemes = [ + { + id: "midnight", + name: "Midnight", + appearance: "dark" as const, + canvas: "#000000", + accent: "#ffffff", + }, + ]; + const subscriberStarted = yield* Deferred.make(); + const subscriber = yield* session.subscribeServerConfig({ environmentThemes: true }).pipe( + Stream.mapEffect((event) => + Deferred.succeed(subscriberStarted, undefined).pipe(Effect.as(event)), + ), + Stream.take(4), + Stream.runCollect, + Effect.forkChild, + ); + yield* Deferred.await(subscriberStarted); + yield* publishConfigEvents(socket, [ + { + version: 1, + type: "environmentThemesUpdated", + payload: { themes: firstThemes }, + }, + { + version: 1, + type: "environmentThemesUpdated", + payload: { themes: replacementThemes }, + }, + { + version: 1, + type: "environmentThemesUpdated", + payload: { themes: [] }, + }, + ]); + + const liveEvents = Array.from(yield* Fiber.join(subscriber)); + expect(liveEvents.map((event) => event.type)).toEqual([ + "snapshot", + "environmentThemesUpdated", + "environmentThemesUpdated", + "environmentThemesUpdated", + ]); + expect(liveEvents[2]).toMatchObject({ payload: { themes: replacementThemes } }); + + const replay = Array.from( + yield* session + .subscribeServerConfig({ environmentThemes: true }) + .pipe(Stream.take(2), Stream.runCollect), + ); + expect(replay.map((event) => event.type)).toEqual(["snapshot", "environmentThemesUpdated"]); + expect(replay[1]).toMatchObject({ payload: { themes: [] } }); + + let projection = applyServerConfigProjection(Option.none(), { + version: 1, + type: "snapshot", + config: THEME_SERVER_CONFIG, + }); + projection = applyServerConfigProjection(projection, { + version: 1, + type: "environmentThemesUpdated", + payload: { themes: firstThemes }, + }); + for (const event of replay) { + projection = applyServerConfigProjection(projection, event); + } + expect(Option.getOrThrow(projection).config.environmentThemes).toBeUndefined(); + }), + ), + ); + + it.effect("recovers a slow subscriber after it misses theme deletion", () => + Effect.scoped( + Effect.gen(function* () { + const { factory, sockets } = yield* makeFactory({ environmentThemes: true }); + const session = yield* factory.connect(PREPARED); + const readyFiber = yield* Effect.forkChild(session.ready); + const socket = yield* awaitSocket(sockets); + socket.open(); + yield* completeInitialConfig(socket, ENCODED_THEME_SERVER_CONFIG, { + environmentThemes: true, + }); + yield* Fiber.join(readyFiber); + + const slowSubscriberStarted = yield* Deferred.make(); + const releaseSlowSubscriber = yield* Deferred.make(); + let firstEvent = true; + const slowSubscriber = yield* session + .subscribeServerConfig({ environmentThemes: true }) + .pipe( + Stream.mapEffect((event) => { + if (!firstEvent) return Effect.succeed(event); + firstEvent = false; + return Deferred.succeed(slowSubscriberStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseSlowSubscriber)), + Effect.as(event), + ); + }), + Stream.take(3), + Stream.runCollect, + Effect.forkChild, + ); + yield* Deferred.await(slowSubscriberStarted); + + const firstThemes = [ + { + id: "nightfall", + name: "Nightfall", + appearance: "dark" as const, + canvas: "#1a1b26", + accent: "#7aa2f7", + }, + ]; + const themeEvents: ServerConfigStreamEventType[] = [ + { + version: 1, + type: "environmentThemesUpdated", + payload: { themes: firstThemes }, + }, + { + version: 1, + type: "environmentThemesUpdated", + payload: { + themes: [{ ...firstThemes[0]!, name: "Nightfall 2" }], + }, + }, + { + version: 1, + type: "environmentThemesUpdated", + payload: { themes: [] }, + }, + ]; + const settingsEvents = Array.from( + { length: 65 }, + (): ServerConfigStreamEventType => ({ + version: 1, + type: "settingsUpdated", + payload: { settings: DEFAULT_SERVER_SETTINGS }, + }), + ); + const allEvents = [...themeEvents, ...settingsEvents]; + const observedByFastSubscriber = yield* Queue.unbounded(); + yield* session.subscribeServerConfig({ environmentThemes: true }).pipe( + Stream.runForEach((event) => Queue.offer(observedByFastSubscriber, event)), + Effect.forkChild, + ); + expect((yield* Queue.take(observedByFastSubscriber)).type).toBe("snapshot"); + for (const event of allEvents) { + yield* publishConfigEvents(socket, [event]); + expect(yield* Queue.take(observedByFastSubscriber)).toEqual(event); + } + yield* Deferred.succeed(releaseSlowSubscriber, undefined); + + const recovered = Array.from(yield* Fiber.join(slowSubscriber)); + expect(recovered.map((event) => event.type)).toEqual([ + "snapshot", + "snapshot", + "environmentThemesUpdated", + ]); + expect(recovered[2]).toMatchObject({ payload: { themes: [] } }); + + let projection = applyServerConfigProjection(Option.none(), { + version: 1, + type: "snapshot", + config: THEME_SERVER_CONFIG, + }); + projection = applyServerConfigProjection(projection, themeEvents[0]!); + for (const event of recovered.slice(1)) { + projection = applyServerConfigProjection(projection, event); + } + expect(Option.getOrThrow(projection).config.environmentThemes).toBeUndefined(); + }), + ), + ); + + it.effect("closes the session when the config source dies", () => + Effect.scoped( + Effect.gen(function* () { + const { factory, sockets } = yield* makeFactory(); + const session = yield* factory.connect(PREPARED); + const readyFiber = yield* Effect.forkChild(session.ready); + const socket = yield* awaitSocket(sockets); + socket.open(); + yield* completeInitialConfig(socket); + yield* Fiber.join(readyFiber); + + const closedFiber = yield* session.closed.pipe(Effect.exit, Effect.forkChild); + socket.serverMessage( + encodeJson({ + _tag: "Defect", + defect: encodeDefect(new Error("config stream died")), + }), + ); + + const closed = yield* Fiber.join(closedFiber); + expect(Exit.isFailure(closed)).toBe(true); + if (Exit.isFailure(closed)) { + expect(Cause.hasDies(closed.cause)).toBe(true); + } + }), + ), + ); + + it.effect.each([{ failure: "defect" as const }, { failure: "typed" as const }])( + "keeps durable config state alive after an owned $failure failure", + ({ failure }) => + Effect.scoped( + Effect.gen(function* () { + const { factory, sockets } = yield* makeFactory({ environmentThemes: true }); + const firstSession = yield* factory.connect(PREPARED); + const firstReady = yield* Effect.forkChild(firstSession.ready); + const firstSocket = yield* awaitSocket(sockets); + firstSocket.open(); + yield* completeInitialConfig(firstSocket, ENCODED_THEME_SERVER_CONFIG, { + environmentThemes: true, + }); + yield* Fiber.join(firstReady); + + const activeSession = yield* SubscriptionRef.make(Option.some(firstSession)); + const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ + target: TARGET, + state: yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE), + session: activeSession, + prepared: yield* SubscriptionRef.make(Option.some(PREPARED)), + connect: Effect.void, + disconnect: Effect.void, + retryNow: Effect.void, + } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); + const cache = Persistence.EnvironmentCacheStore.of({ + loadShell: () => Effect.succeed(Option.none()), + saveShell: () => Effect.void, + loadThread: () => Effect.succeed(Option.none()), + saveThread: () => Effect.void, + removeThread: () => Effect.void, + loadServerConfig: () => Effect.succeed(Option.none()), + saveServerConfig: () => Effect.void, + loadVcsRefs: () => Effect.succeed(Option.none()), + saveVcsRefs: () => Effect.void, + removeVcsRefs: () => Effect.void, + clearVcsRefs: () => Effect.void, + clear: () => Effect.void, + }); + const configState = yield* makeEnvironmentServerConfigState(true).pipe( + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + Effect.provideService(Persistence.EnvironmentCacheStore, cache), + ); + const awaitConfig = (predicate: (config: ServerConfigType) => boolean) => + SubscriptionRef.changes(configState).pipe( + Stream.filter(Option.isSome), + Stream.map((projection) => projection.value.config), + Stream.filter(predicate), + Stream.runHead, + Effect.map(Option.getOrThrow), + ); + + const firstThemes = [ + { + id: "first-theme", + name: "First theme", + appearance: "dark" as const, + canvas: "#111111", + accent: "#ffffff", + }, + ]; + const firstThemeState = yield* awaitConfig( + (config) => config.environmentThemes?.[0]?.id === "first-theme", + ).pipe(Effect.forkChild); + yield* publishConfigEvents(firstSocket, [ + { + version: 1, + type: "environmentThemesUpdated", + payload: { themes: firstThemes }, + }, + ]); + expect((yield* Fiber.join(firstThemeState)).environmentThemes).toEqual(firstThemes); + + const firstClosed = yield* firstSession.closed.pipe(Effect.exit, Effect.forkChild); + const firstRequest = yield* awaitRequest(firstSocket); + firstSocket.serverMessage( + failure === "defect" + ? encodeJson({ + _tag: "Defect", + defect: encodeDefect(new Error("config stream died")), + }) + : encodeJson({ + _tag: "Exit", + requestId: firstRequest.id, + exit: { + _tag: "Failure", + cause: [ + { + _tag: "Fail", + error: { + _tag: "EnvironmentAuthorizationError", + message: "config subscription rejected", + requiredScope: "orchestration:read", + }, + }, + ], + }, + }), + ); + const firstClosedExit = yield* Fiber.join(firstClosed); + expect(Exit.isFailure(firstClosedExit)).toBe(true); + if (failure === "typed" && Exit.isFailure(firstClosedExit)) { + expect(Cause.squash(firstClosedExit.cause)).toBeInstanceOf(ConnectionBlockedError); + expect(Cause.squash(firstClosedExit.cause)).toMatchObject({ reason: "permission" }); + } + yield* SubscriptionRef.set(activeSession, Option.none()); + + const recoveredConfig = { + ...THEME_SERVER_CONFIG, + environment: { + ...THEME_SERVER_CONFIG.environment, + label: "Recovered environment", + }, + } satisfies ServerConfigType; + const secondSession = yield* factory.connect(PREPARED); + const secondReady = yield* Effect.forkChild(secondSession.ready); + const secondSocket = yield* awaitSocket(sockets, 1); + secondSocket.open(); + yield* completeInitialConfig(secondSocket, encodeServerConfig(recoveredConfig), { + environmentThemes: true, + }); + yield* Fiber.join(secondReady); + + const recoveredState = yield* awaitConfig( + (config) => config.environment.label === "Recovered environment", + ).pipe(Effect.forkChild); + yield* SubscriptionRef.set(activeSession, Option.some(secondSession)); + expect((yield* Fiber.join(recoveredState)).environmentThemes).toEqual(firstThemes); + + const recoveredThemes = [ + { + id: "recovered-theme", + name: "Recovered theme", + appearance: "dark" as const, + canvas: "#000000", + accent: "#eeeeee", + }, + ]; + const liveRecoveredState = yield* awaitConfig( + (config) => config.environmentThemes?.[0]?.id === "recovered-theme", + ).pipe(Effect.forkChild); + yield* publishConfigEvents(secondSocket, [ + { + version: 1, + type: "environmentThemesUpdated", + payload: { themes: recoveredThemes }, + }, + ]); + expect((yield* Fiber.join(liveRecoveredState)).environmentThemes).toEqual( + recoveredThemes, + ); + }), + ), + ); + + it.effect.each<{ + readonly event: ServerConfigStreamEventType; + readonly expectedConfig: Partial; + }>([ + { + event: { + version: 1, + type: "providerStatuses", + payload: { + providers: [ + { + instanceId: ProviderInstanceId.make("codex"), + driver: ProviderDriverKind.make("codex"), + enabled: true, + installed: true, + version: "1.0.0", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-08-27T00:00:00.000Z", + models: [], + slashCommands: [], + skills: [], + }, + ], + }, + }, + expectedConfig: { + providers: [ + { + instanceId: ProviderInstanceId.make("codex"), + driver: ProviderDriverKind.make("codex"), + enabled: true, + installed: true, + version: "1.0.0", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-08-27T00:00:00.000Z", + models: [], + slashCommands: [], + skills: [], + }, + ], + }, + }, + { + event: { + version: 1, + type: "settingsUpdated", + payload: { + settings: { + ...DEFAULT_SERVER_SETTINGS, + newWorktreesStartFromOrigin: !DEFAULT_SERVER_SETTINGS.newWorktreesStartFromOrigin, + }, + }, + }, + expectedConfig: { + settings: { + ...DEFAULT_SERVER_SETTINGS, + newWorktreesStartFromOrigin: !DEFAULT_SERVER_SETTINGS.newWorktreesStartFromOrigin, + }, + }, + }, + ])( + "preserves $event.type events and includes them in replay snapshots", + ({ event, expectedConfig }) => + Effect.scoped( + Effect.gen(function* () { + const { factory, sockets } = yield* makeFactory(); + const session = yield* factory.connect(PREPARED); + const readyFiber = yield* Effect.forkChild(session.ready); + const socket = yield* awaitSocket(sockets); + socket.open(); + yield* completeInitialConfig(socket); + yield* Fiber.join(readyFiber); + + const subscriber = yield* session + .subscribeServerConfig({}) + .pipe(Stream.take(2), Stream.runCollect, Effect.forkChild); + yield* Effect.yieldNow; + + const request = yield* awaitRequest(socket); + socket.serverMessage( + encodeJson({ + _tag: "Chunk", + requestId: request.id, + values: [encodeServerConfigStreamEvent(event)], + }), + ); + + const events = Array.from(yield* Fiber.join(subscriber)); + expect(events[1]).toEqual(event); + + const replay = yield* session.subscribeServerConfig({}).pipe(Stream.runHead); + expect(replay).toMatchObject({ + _tag: "Some", + value: { + type: "snapshot", + config: expectedConfig, + }, + }); + }), + ), + ); + it.effect("tolerates two missed pong windows before closing the session", () => Effect.gen(function* () { const { factory, sockets } = yield* makeFactory(); @@ -301,7 +951,7 @@ describe("RpcSessionFactory", () => { yield* TestClock.adjust("15 seconds"); expect(closedFiber.pollUnsafe()).toBeUndefined(); - expect(socket.sent.slice(1).map((request) => decodeJson(request))).toEqual([ + expect(socket.sent.map((message) => decodeJson(message)).filter(isPing)).toEqual([ { _tag: "Ping" }, { _tag: "Ping" }, { _tag: "Ping" }, @@ -379,10 +1029,12 @@ describe("RpcSessionFactory", () => { ); yield* Fiber.join(probeFiber); - expect(socket.sent.map((request) => decodeRpcRequest(decodeJson(request)).tag)).toEqual([ - WS_METHODS.serverGetConfig, - WS_METHODS.serverGetConfig, - ]); + expect( + socket.sent + .map((message) => decodeJson(message)) + .filter(isRpcRequest) + .map((request) => request.tag), + ).toEqual([WS_METHODS.subscribeServerConfig, WS_METHODS.serverGetConfig]); }), ), ); diff --git a/packages/client-runtime/src/rpc/session.ts b/packages/client-runtime/src/rpc/session.ts index 9625effa406f..7d975be5c9d3 100644 --- a/packages/client-runtime/src/rpc/session.ts +++ b/packages/client-runtime/src/rpc/session.ts @@ -1,11 +1,25 @@ -import { type ServerConfig, WS_METHODS } from "@t3tools/contracts"; +import { + type ServerConfig, + type ServerConfigStreamEvent, + WsSubscribeServerConfigRpc, + WS_METHODS, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Equal from "effect/Equal"; +import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as PubSub from "effect/PubSub"; +import * as Ref from "effect/Ref"; import * as Schedule from "effect/Schedule"; import type * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; +import type * as Rpc from "effect/unstable/rpc/Rpc"; import * as RpcClient from "effect/unstable/rpc/RpcClient"; +import * as RpcClientError from "effect/unstable/rpc/RpcClientError"; import * as RpcSerialization from "effect/unstable/rpc/RpcSerialization"; import * as Socket from "effect/unstable/socket/Socket"; @@ -19,15 +33,27 @@ import { ConnectionBlockedError, ConnectionTransientError as ConnectionTransientErrorClass, } from "../connection/model.ts"; +import { + applyServerConfigProjection, + type ServerConfigProjection, + withoutEnvironmentThemes, +} from "../state/serverConfigProjection.ts"; const SOCKET_OPEN_TIMEOUT = "15 seconds"; export interface RpcSession { readonly client: WsRpcProtocolClient; readonly initialConfig: Effect.Effect; + readonly subscribeServerConfig: ( + input: ServerConfigSubscriptionInput, + ) => ServerConfigSubscription; readonly ready: Effect.Effect; readonly probe: Effect.Effect; - readonly closed: Effect.Effect; + readonly closed: Effect.Effect; +} + +export interface RpcSessionOptions { + readonly environmentThemes?: boolean; } export class RpcSessionFactory extends Context.Service< @@ -43,8 +69,47 @@ type InitialConfigError = Effect.Error< ReturnType >; type ProbeError = Effect.Error>; +type ServerConfigSubscriptionError = + | Rpc.ErrorExit + | RpcClientError.RpcClientError; +type ServerConfigSubscription = Stream.Stream< + ServerConfigStreamEvent, + ServerConfigSubscriptionError +>; +type ServerConfigSubscriptionInput = Parameters< + WsRpcProtocolClient[typeof WS_METHODS.subscribeServerConfig] +>[0]; +type EnvironmentThemesUpdatedEvent = Extract< + ServerConfigStreamEvent, + { readonly type: "environmentThemesUpdated" } +>; + +interface ServerConfigReplayState { + readonly projection: ServerConfigProjection; + readonly revision: number; + readonly themesEvent: EnvironmentThemesUpdatedEvent | undefined; +} + +interface BufferedServerConfigEvent { + readonly event: ServerConfigStreamEvent; + readonly replay: ServerConfigReplayState; + readonly revision: number; +} + +function serverConfigReplayEvents( + state: ServerConfigReplayState, +): ReadonlyArray { + const snapshot = { + version: 1 as const, + type: "snapshot" as const, + config: withoutEnvironmentThemes(state.projection.config), + }; + return state.themesEvent === undefined ? [snapshot] : [snapshot, state.themesEvent]; +} -function mapSessionRpcError(error: InitialConfigError | ProbeError): ConnectionAttemptError { +function mapSessionRpcError( + error: InitialConfigError | ProbeError | ServerConfigSubscriptionError, +): ConnectionAttemptError { switch (error._tag) { case "EnvironmentAuthorizationError": return new ConnectionBlockedError({ @@ -65,8 +130,12 @@ function mapSessionRpcError(error: InitialConfigError | ProbeError): ConnectionA } } -export const make = Effect.gen(function* () { +export const make = Effect.fn("RpcSessionFactory.make")(function* ( + options: RpcSessionOptions = {}, +) { const webSocketConstructor = yield* Socket.WebSocketConstructor; + const serverConfigInput: ServerConfigSubscriptionInput = + options.environmentThemes === true ? { environmentThemes: true } : {}; const connect = Effect.fnUntraced(function* (connection: PreparedConnection) { yield* Effect.annotateCurrentSpan({ @@ -113,18 +182,135 @@ export const make = Effect.gen(function* () { const protocolContext = yield* Layer.build(protocolLayer).pipe( Effect.withSpan("environment.websocket.connect"), ); - const client = yield* makeWsRpcProtocolClient.pipe(Effect.provide(protocolContext)); - const initialConfig = yield* Effect.cached( - client[WS_METHODS.serverGetConfig]({}).pipe( + const protocolClient = yield* makeWsRpcProtocolClient.pipe(Effect.provide(protocolContext)); + const initialConfigDeferred = yield* Deferred.make(); + const serverConfigExit = yield* Deferred.make(); + const configSubscriptionClosed = yield* Deferred.make(); + const serverConfigState = yield* Ref.make(Option.none()); + const serverConfigUpdates = yield* PubSub.sliding(64); + const configSubscriptionEndedError = new ConnectionTransientErrorClass({ + reason: "remote-unavailable", + detail: `${connection.label} config subscription ended.`, + }); + const serverConfigSource = protocolClient[WS_METHODS.subscribeServerConfig]( + serverConfigInput, + ).pipe( + Stream.runForEach((event) => + Effect.gen(function* () { + const buffered = yield* Ref.modify(serverConfigState, (current) => { + const projection = applyServerConfigProjection( + Option.map(current, (state) => state.projection), + event, + ); + if (Option.isNone(projection)) { + return [Option.none(), current] as const; + } + const next = { + projection: projection.value, + revision: Option.match(current, { + onNone: () => 1, + onSome: (state) => state.revision + 1, + }), + themesEvent: + event.type === "environmentThemesUpdated" + ? event + : event.type === "snapshot" && + event.config.environment.capabilities.environmentThemes !== true + ? undefined + : Option.getOrUndefined(current)?.themesEvent, + } satisfies ServerConfigReplayState; + return [ + Option.some({ event, replay: next, revision: next.revision }), + Option.some(next), + ] as const; + }); + if (Option.isSome(buffered)) { + yield* PubSub.publish(serverConfigUpdates, buffered.value); + } + if (event.type === "snapshot") { + yield* Deferred.succeed(initialConfigDeferred, event.config); + } + }), + ), + Effect.onExit((exit) => { + if (Exit.isSuccess(exit)) { + return Effect.all([ + Deferred.succeed(serverConfigExit, undefined), + Deferred.fail(configSubscriptionClosed, configSubscriptionEndedError), + ]).pipe(Effect.asVoid); + } + if (Cause.hasInterruptsOnly(exit.cause)) { + return Effect.void; + } + return Effect.all([ + Deferred.failCause(serverConfigExit, exit.cause), + Deferred.failCause(configSubscriptionClosed, Cause.map(exit.cause, mapSessionRpcError)), + ]).pipe(Effect.asVoid); + }), + ); + yield* serverConfigSource.pipe(Effect.forkScoped); + const initialConfig = Effect.raceFirst( + Deferred.await(initialConfigDeferred), + Deferred.await(serverConfigExit).pipe( Effect.mapError(mapSessionRpcError), - Effect.withSpan("environment.initialSync"), + Effect.flatMap(() => Effect.fail(configSubscriptionEndedError)), ), + ).pipe(Effect.withSpan("environment.initialSync")); + const serverConfigEvents = Stream.unwrap( + Effect.gen(function* () { + const subscription = yield* PubSub.subscribe(serverConfigUpdates); + yield* Effect.raceFirst( + Deferred.await(initialConfigDeferred).pipe(Effect.asVoid), + Deferred.await(serverConfigExit), + ); + const snapshot = yield* Ref.get(serverConfigState); + if (Option.isNone(snapshot)) { + return Stream.empty; + } + const updates = Stream.fromSubscription(subscription).pipe( + Stream.filter((buffered) => buffered.revision > snapshot.value.revision), + Stream.mapAccum( + () => snapshot.value.revision, + (revision, buffered) => [ + buffered.revision, + buffered.revision === revision + 1 + ? [buffered.event] + : serverConfigReplayEvents(buffered.replay), + ], + ), + ); + const terminal = Stream.fromEffect(Deferred.await(serverConfigExit)).pipe(Stream.drain); + return Stream.concat( + Stream.fromIterable(serverConfigReplayEvents(snapshot.value)), + Stream.merge(updates, terminal, { haltStrategy: "either" }), + ); + }), + ).pipe( + Stream.catchCause((cause) => { + if (Cause.hasInterruptsOnly(cause)) { + return Stream.failCause(cause); + } + // The supervisor keeps the original cause. Shared durable consumers + // need a transport-shaped failure so they wait for its replacement. + return Stream.fail( + new RpcClientError.RpcClientError({ + reason: new RpcClientError.RpcClientDefect({ + message: `${connection.label} config subscription failed.`, + cause, + }), + }), + ); + }), ); + const subscribeServerConfig = (input: ServerConfigSubscriptionInput) => + Equal.equals(input, serverConfigInput) + ? serverConfigEvents + : protocolClient[WS_METHODS.subscribeServerConfig](input); const probe = initialConfig.pipe( Effect.flatMap((config) => (config.environment.capabilities.connectionProbe === true - ? client[WS_METHODS.serverProbe]({}) - : client[WS_METHODS.serverGetConfig]({}) + ? protocolClient[WS_METHODS.serverProbe]({}) + : protocolClient[WS_METHODS.serverGetConfig]({}) ).pipe(Effect.mapError(mapSessionRpcError)), ), Effect.asVoid, @@ -132,19 +318,26 @@ export const make = Effect.gen(function* () { ); return { - client, + client: protocolClient, initialConfig, + subscribeServerConfig, ready: Deferred.await(connected).pipe( Effect.andThen(initialConfig), Effect.asVoid, Effect.raceFirst(Deferred.await(disconnected)), ), probe, - closed: Deferred.await(disconnected), + closed: Effect.raceFirst( + Deferred.await(disconnected), + Deferred.await(configSubscriptionClosed), + ), } satisfies RpcSession; }); return RpcSessionFactory.of({ connect }); }); -export const layer = Layer.effect(RpcSessionFactory, make); +export const layerWithOptions = (options: RpcSessionOptions) => + Layer.effect(RpcSessionFactory, make(options)); + +export const layer = layerWithOptions({}); diff --git a/packages/client-runtime/src/state/attachments.test.ts b/packages/client-runtime/src/state/attachments.test.ts new file mode 100644 index 000000000000..f09c47362241 --- /dev/null +++ b/packages/client-runtime/src/state/attachments.test.ts @@ -0,0 +1,202 @@ +import { describe, expect, it } from "@effect/vitest"; +import { + EnvironmentId, + PROVIDER_SEND_TURN_MAX_FILE_BYTES, + type AttachmentCreateUploadUrlInput, + type AttachmentCreateUploadUrlResult, + type AttachmentDeleteInput, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; + +import type { AtomCommand } from "./runtime.ts"; +import { + clampFileAttachmentUploadBytes, + fileAttachmentTooLargeMessage, + formatAttachmentSize, + runAttachmentUploadCycle, + verifyPersistedAttachmentUpload, +} from "./attachments.ts"; + +const environmentId = EnvironmentId.make("environment-1"); +// The cycle threads the registry through to the commands untouched, so the +// fakes below can ignore it. +const registry = {} as AtomRegistry.AtomRegistry; + +type CreateUploadUrlCommand = AtomCommand< + { readonly environmentId: EnvironmentId; readonly input: AttachmentCreateUploadUrlInput }, + AttachmentCreateUploadUrlResult, + never +>; + +type RemoveCommand = AtomCommand< + { readonly environmentId: EnvironmentId; readonly input: AttachmentDeleteInput }, + unknown, + never +>; + +function makeCreateUploadUrl(attachmentId: string): CreateUploadUrlCommand { + return { + label: "test:create-upload-url", + run: async () => + AsyncResult.success({ + attachmentId, + relativeUrl: `/api/attachments/upload/${attachmentId}`, + expiresAt: 1, + }), + }; +} + +const removeCalls: string[] = []; +const remove: RemoveCommand = { + label: "test:remove", + run: async (_registry, input) => { + removeCalls.push(input.input.attachmentId); + return AsyncResult.success(undefined); + }, +}; + +const uploadInput: AttachmentCreateUploadUrlInput = { + type: "file", + name: "report.pdf", + mimeType: "application/pdf", + sizeBytes: 3, +}; + +describe("runAttachmentUploadCycle", () => { + it("mints, transfers, and reports the attachment id", async () => { + const transferred: string[] = []; + const result = await runAttachmentUploadCycle({ + registry, + createUploadUrl: makeCreateUploadUrl("pending-1"), + remove, + environmentId, + upload: uploadInput, + resolveUploadUrl: (relativeUrl) => `https://environment.test${relativeUrl}`, + transport: (url) => { + transferred.push(url); + return { done: Promise.resolve(), abort: () => {} }; + }, + }); + + expect(result).toEqual({ status: "uploaded", attachmentId: "pending-1" }); + expect(transferred).toEqual(["https://environment.test/api/attachments/upload/pending-1"]); + }); + + it("deletes the fresh mint when the caller cancels at onMinted", async () => { + removeCalls.length = 0; + const result = await runAttachmentUploadCycle({ + registry, + createUploadUrl: makeCreateUploadUrl("pending-cancelled"), + remove, + environmentId, + upload: uploadInput, + resolveUploadUrl: () => "https://environment.test/upload", + transport: () => { + throw new Error("transport must not run after cancel"); + }, + onMinted: () => "cancel", + }); + + expect(result).toEqual({ status: "cancelled", attachmentId: "pending-cancelled" }); + expect(removeCalls).toEqual(["pending-cancelled"]); + }); + + it("keeps the minted id on transfer failure so the caller can retry or release", async () => { + removeCalls.length = 0; + const result = await runAttachmentUploadCycle({ + registry, + createUploadUrl: makeCreateUploadUrl("pending-failed"), + remove, + environmentId, + upload: uploadInput, + resolveUploadUrl: () => "https://environment.test/upload", + transport: () => ({ + done: Promise.reject(new Error("Upload rejected (413)")), + abort: () => {}, + }), + }); + + expect(result).toMatchObject({ + status: "failed", + step: "transfer", + attachmentId: "pending-failed", + }); + expect(removeCalls).toEqual([]); + }); +}); + +describe("verifyPersistedAttachmentUpload", () => { + it("hits the server on every verification instead of reusing a cached failure", async () => { + // Mirrors the app's asset URL query atom: SWR-cached with a long stale + // window and kept alive across calls. Without a forced refresh, the + // second verification would read the cached failure and never retry. + let lookups = 0; + const assetUrlAtom = Atom.make( + // Async like the real RPC, so the first read is still in flight when + // the query decides whether a refresh is needed. + Effect.promise(() => Promise.resolve()).pipe( + Effect.flatMap(() => { + lookups += 1; + return lookups === 1 + ? Effect.fail({ _tag: "TransportError" } as const) + : Effect.succeed({ url: "/api/assets/pending-1" }); + }), + ), + ).pipe(Atom.swr({ staleTime: 60_000 }), Atom.keepAlive); + const liveRegistry = AtomRegistry.make(); + + const verify = () => + verifyPersistedAttachmentUpload({ + registry: liveRegistry, + createAssetUrl: () => assetUrlAtom, + environmentId, + attachmentId: "pending-1", + }); + + const first = await verify(); + expect(first).toMatchObject({ status: "failed" }); + + const second = await verify(); + expect(second).toEqual({ status: "verified" }); + expect(lookups).toBe(2); + }); +}); + +describe("file attachment limits", () => { + it("clamps the advertised limit to the turn contract cap", () => { + expect(clampFileAttachmentUploadBytes(1024)).toBe(1024); + expect(clampFileAttachmentUploadBytes(PROVIDER_SEND_TURN_MAX_FILE_BYTES * 2)).toBe( + PROVIDER_SEND_TURN_MAX_FILE_BYTES, + ); + }); + + it("formats attachment row sizes", () => { + expect(formatAttachmentSize(3 * 1024 * 1024)).toBe("3.0 MB"); + expect(formatAttachmentSize(1)).toBe("1 KB"); + }); + + it("formats small upload limits without rounding them to zero MB", () => { + expect(fileAttachmentTooLargeMessage("tiny.txt", 1)).toBe( + "'tiny.txt' exceeds the 1 byte attachment limit.", + ); + expect(fileAttachmentTooLargeMessage("small.txt", 1024)).toBe( + "'small.txt' exceeds the 1 KB attachment limit.", + ); + expect(fileAttachmentTooLargeMessage("exact.txt", 1025)).toBe( + "'exact.txt' exceeds the 1025 bytes attachment limit.", + ); + expect(fileAttachmentTooLargeMessage("medium.zip", 512 * 1024)).toBe( + "'medium.zip' exceeds the 512 KB attachment limit.", + ); + }); + + it("keeps whole-MB upload limits for standard server caps", () => { + expect(fileAttachmentTooLargeMessage("one.bin", 1024 * 1024)).toBe( + "'one.bin' exceeds the 1 MB attachment limit.", + ); + expect(fileAttachmentTooLargeMessage("big.zip", 50 * 1024 * 1024)).toBe( + "'big.zip' exceeds the 50 MB attachment limit.", + ); + }); +}); diff --git a/packages/client-runtime/src/state/attachments.ts b/packages/client-runtime/src/state/attachments.ts new file mode 100644 index 000000000000..0dc457e2cade --- /dev/null +++ b/packages/client-runtime/src/state/attachments.ts @@ -0,0 +1,236 @@ +import { + PROVIDER_SEND_TURN_MAX_FILE_BYTES, + WS_METHODS, + type AttachmentCreateUploadUrlInput, + type AttachmentCreateUploadUrlResult, + type AttachmentDeleteInput, + type EnvironmentId, +} from "@t3tools/contracts"; +import type { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; + +import type { EnvironmentRegistry } from "../connection/registry.ts"; +import { + createEnvironmentRpcCommand, + executeAtomQuery, + runAtomCommand, + squashAtomCommandFailure, + type AtomCommand, +} from "./runtime.ts"; + +/** + * RPC commands for pending chat attachment uploads. Mirrors + * `createAssetEnvironmentAtoms`: each client instantiates it with its own + * connection runtime (`attachmentEnvironment` in web and mobile). + */ +export function createAttachmentEnvironmentAtoms( + runtime: Atom.AtomRuntime, +) { + return { + createUploadUrl: createEnvironmentRpcCommand(runtime, { + label: "environment-command:attachments:create-upload-url", + tag: WS_METHODS.attachmentsCreateUploadUrl, + }), + remove: createEnvironmentRpcCommand(runtime, { + label: "environment-command:attachments:delete", + tag: WS_METHODS.attachmentsDelete, + }), + }; +} + +/** + * Whether a failed asset lookup means the attachment no longer exists on the + * server, as opposed to a transient transport failure. Pending uploads expire, + * so this is the signal to upload the bytes again rather than retry the lookup. + * + * A structural `_tag` check rather than a schema check: the squashed cause of + * a failed RPC is not guaranteed to be a decoded error class instance, only a + * tagged value. + */ +export function isAssetAttachmentNotFoundFailure(error: unknown): boolean { + return ( + typeof error === "object" && + error !== null && + "_tag" in error && + error._tag === "AssetAttachmentNotFoundError" + ); +} + +export type PersistedAttachmentVerification = + | { readonly status: "verified" } + | { readonly status: "missing" } + | { readonly status: "failed"; readonly error: unknown }; + +/** + * Checks that a previously uploaded pending attachment still exists on the + * server by minting an asset URL for it. `verified` means the send can reuse + * the stored bytes, `missing` means the upload expired and the bytes must be + * uploaded again, `failed` means the server could not be asked. + */ +export async function verifyPersistedAttachmentUpload(input: { + readonly registry: AtomRegistry.AtomRegistry; + readonly createAssetUrl: (query: { + readonly environmentId: EnvironmentId; + readonly input: { + readonly resource: { readonly _tag: "attachment"; readonly attachmentId: string }; + }; + }) => Atom.Atom>; + readonly environmentId: EnvironmentId; + readonly attachmentId: string; +}): Promise { + const result = await executeAtomQuery( + input.registry, + input.createAssetUrl({ + environmentId: input.environmentId, + input: { resource: { _tag: "attachment", attachmentId: input.attachmentId } }, + }), + // `refresh` forces a server round trip: the asset URL query atom caches + // results (SWR), so a retry right after a transient failure would + // otherwise re-observe the cached failure and never ask the server. + { reportFailure: false, reportDefect: false, refresh: true }, + ); + if (result._tag === "Success") { + return { status: "verified" }; + } + const error = squashAtomCommandFailure(result); + return isAssetAttachmentNotFoundFailure(error) + ? { status: "missing" } + : { status: "failed", error }; +} + +type AttachmentCreateUploadUrlCommand = AtomCommand< + { readonly environmentId: EnvironmentId; readonly input: AttachmentCreateUploadUrlInput }, + AttachmentCreateUploadUrlResult, + E +>; + +type AttachmentRemoveCommand = AtomCommand< + { readonly environmentId: EnvironmentId; readonly input: AttachmentDeleteInput }, + unknown, + E +>; + +/** Fire-and-forget delete of a pending upload the client no longer references. */ +export function deletePendingAttachmentUpload(input: { + readonly registry: AtomRegistry.AtomRegistry; + readonly remove: AttachmentRemoveCommand; + readonly environmentId: EnvironmentId; + readonly attachmentId: string; +}): void { + void runAtomCommand( + input.registry, + input.remove, + { environmentId: input.environmentId, input: { attachmentId: input.attachmentId } }, + { reportFailure: false, reportDefect: false }, + ); +} + +/** A running byte transfer: resolves when the server accepted the bytes. */ +export interface AttachmentByteUpload { + readonly done: Promise; + readonly abort: () => void; +} + +export type AttachmentUploadCycleResult = + | { readonly status: "uploaded"; readonly attachmentId: string } + | { readonly status: "cancelled"; readonly attachmentId: string | null } + | { + readonly status: "failed"; + readonly step: "mint" | "resolve-url" | "transfer"; + readonly attachmentId: string | null; + readonly error: unknown; + }; + +/** + * The platform-neutral upload cycle: mint a signed upload URL, resolve it + * against the environment's HTTP base, and hand the bytes to a + * platform-specific transport (XHR on web, `expo-file-system` on mobile). + * + * The cycle never deletes the minted pending upload on failure: callers keep + * the returned `attachmentId` and decide between retry and release. The one + * exception is `onMinted` returning `"cancel"`, where the caller already + * abandoned the upload and the fresh mint is deleted before returning. + */ +export async function runAttachmentUploadCycle(input: { + readonly registry: AtomRegistry.AtomRegistry; + readonly createUploadUrl: AttachmentCreateUploadUrlCommand; + readonly remove: AttachmentRemoveCommand; + readonly environmentId: EnvironmentId; + readonly upload: AttachmentCreateUploadUrlInput; + readonly resolveUploadUrl: (relativeUrl: string) => string | null; + readonly transport: (url: string) => AttachmentByteUpload; + /** Observe the minted id (for cancellation bookkeeping) before bytes move. */ + readonly onMinted?: (attachmentId: string) => "continue" | "cancel"; + readonly onTransferStart?: (abort: () => void) => void; +}): Promise { + const minted = await runAtomCommand( + input.registry, + input.createUploadUrl, + { environmentId: input.environmentId, input: input.upload }, + { reportFailure: false }, + ); + if (minted._tag !== "Success") { + return { + status: "failed", + step: "mint", + attachmentId: null, + error: squashAtomCommandFailure(minted), + }; + } + const attachmentId = minted.value.attachmentId; + if (input.onMinted?.(attachmentId) === "cancel") { + deletePendingAttachmentUpload({ + registry: input.registry, + remove: input.remove, + environmentId: input.environmentId, + attachmentId, + }); + return { status: "cancelled", attachmentId }; + } + + const url = input.resolveUploadUrl(minted.value.relativeUrl); + if (!url) { + return { + status: "failed", + step: "resolve-url", + attachmentId, + error: new Error("The environment is not connected."), + }; + } + + const transfer = input.transport(url); + input.onTransferStart?.(transfer.abort); + try { + await transfer.done; + } catch (error) { + return { status: "failed", step: "transfer", attachmentId, error }; + } + return { status: "uploaded", attachmentId }; +} + +/** + * The effective per-file byte limit for a server that advertises + * `capabilities.fileAttachments.maxUploadBytes`. The contract caps what a + * turn may reference, so a larger advertised value must not admit files the + * send would then refuse. + */ +export function clampFileAttachmentUploadBytes(advertisedMaxUploadBytes: number): number { + return Math.min(advertisedMaxUploadBytes, PROVIDER_SEND_TURN_MAX_FILE_BYTES); +} + +/** "3.2 MB" / "48 KB" label for attachment rows. Never shows "0 KB". */ +export function formatAttachmentSize(sizeBytes: number): string { + return sizeBytes >= 1024 * 1024 + ? `${(sizeBytes / (1024 * 1024)).toFixed(1)} MB` + : `${Math.max(1, Math.ceil(sizeBytes / 1024))} KB`; +} + +/** User-facing rejection for a file over the effective upload limit. */ +export function fileAttachmentTooLargeMessage(name: string, maxUploadBytes: number): string { + const maxUploadSize = + maxUploadBytes >= 1024 * 1024 && maxUploadBytes % (1024 * 1024) === 0 + ? `${maxUploadBytes / (1024 * 1024)} MB` + : maxUploadBytes >= 1024 && maxUploadBytes % 1024 === 0 + ? `${maxUploadBytes / 1024} KB` + : `${maxUploadBytes} ${maxUploadBytes === 1 ? "byte" : "bytes"}`; + return `'${name}' exceeds the ${maxUploadSize} attachment limit.`; +} diff --git a/packages/client-runtime/src/state/models.ts b/packages/client-runtime/src/state/models.ts index b601b59bfad0..9f4c83609cac 100644 --- a/packages/client-runtime/src/state/models.ts +++ b/packages/client-runtime/src/state/models.ts @@ -2,10 +2,8 @@ import type { EnvironmentId, OrchestrationMessage, OrchestrationProjectShell, - OrchestrationShellSnapshot, OrchestrationThread, OrchestrationThreadShell, - ThreadId, } from "@t3tools/contracts"; export interface EnvironmentProject extends OrchestrationProjectShell { @@ -42,12 +40,3 @@ export function scopeThread( ): EnvironmentThread { return { ...thread, environmentId }; } - -export function selectEnvironmentThreadShell( - snapshot: OrchestrationShellSnapshot | null, - environmentId: EnvironmentId, - threadId: ThreadId, -): EnvironmentThreadShell | null { - const thread = snapshot?.threads.find((candidate) => candidate.id === threadId) ?? null; - return thread ? scopeThreadShell(environmentId, thread) : null; -} diff --git a/packages/client-runtime/src/state/pullRequests.test.ts b/packages/client-runtime/src/state/pullRequests.test.ts new file mode 100644 index 000000000000..618d5c39418b --- /dev/null +++ b/packages/client-runtime/src/state/pullRequests.test.ts @@ -0,0 +1,143 @@ +import { EnvironmentId, ProjectId, WS_METHODS } from "@t3tools/contracts"; +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; +import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; + +import { + AVAILABLE_CONNECTION_STATE, + PrimaryConnectionTarget, + type PreparedConnection, + type SupervisorConnectionState, +} from "../connection/model.ts"; +import * as EnvironmentRegistry from "../connection/registry.ts"; +import * as EnvironmentSupervisor from "../connection/supervisor.ts"; +import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; +import type { RpcSession } from "../rpc/session.ts"; +import { createPullRequestEnvironmentAtoms } from "./pullRequests.ts"; +import { PullRequestDiffLoader } from "./pullRequestDiffHttp.ts"; +import { executeAtomQuery } from "./runtime.ts"; + +const TARGET = new PrimaryConnectionTarget({ + environmentId: EnvironmentId.make("environment-1"), + label: "Test environment", + httpBaseUrl: "https://environment.example.test", + wsBaseUrl: "wss://environment.example.test", +}); + +function session(client: WsRpcProtocolClient): RpcSession { + return { + client, + initialConfig: Effect.never, + subscribeServerConfig: (input) => client.subscribeServerConfig(input), + ready: Effect.void, + probe: Effect.void, + closed: Effect.never, + }; +} + +it.effect("refreshes pull request activity after a comment is updated", () => + Effect.scoped( + Effect.gen(function* () { + let commentBody = "old comment"; + const client = { + [WS_METHODS.pullRequestsActivity]: () => + Effect.succeed({ + author: null, + reviewers: [], + comments: [ + { + id: "comment-1", + kind: "issue-comment", + author: null, + body: commentBody, + createdAt: "2026-08-24T00:00:00Z", + url: null, + path: null, + reviewState: null, + reactions: [], + }, + ], + commentCount: 1, + commentsTruncated: false, + reviewThreads: [], + commits: [], + reactions: [], + }), + [WS_METHODS.pullRequestsUpdateComment]: (input: { readonly body: string }) => + Effect.sync(() => { + commentBody = input.body; + }), + } as unknown as WsRpcProtocolClient; + const connectionState: SupervisorConnectionState = { + ...AVAILABLE_CONNECTION_STATE, + desired: true, + network: "online", + phase: "connected", + attempt: 1, + generation: 1, + }; + const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ + target: TARGET, + state: yield* SubscriptionRef.make(connectionState), + session: yield* SubscriptionRef.make(Option.some(session(client))), + prepared: yield* SubscriptionRef.make(Option.none()), + connect: Effect.void, + disconnect: Effect.void, + retryNow: Effect.void, + } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); + const environmentRegistry = EnvironmentRegistry.EnvironmentRegistry.of({ + run: (_environmentId, effect) => + Effect.provideService(effect, EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + runStream: (_environmentId, stream) => + Stream.provideService(stream, EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + followStream: (_environmentId, stream) => + Stream.provideService(stream, EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + } as EnvironmentRegistry.EnvironmentRegistry["Service"]); + const runtime = Atom.runtime( + Layer.merge( + Layer.succeed(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry), + Layer.succeed( + PullRequestDiffLoader, + PullRequestDiffLoader.of({ load: () => Effect.die("unused") }), + ), + ), + ); + const atoms = createPullRequestEnvironmentAtoms(runtime); + const registry = yield* Effect.acquireRelease(Effect.sync(AtomRegistry.make), (registry) => + Effect.sync(() => registry.dispose()), + ); + const reference = { + projectId: ProjectId.make("project-1"), + repository: "acme/web", + number: 1, + } as const; + const activity = atoms.activity({ environmentId: TARGET.environmentId, input: reference }); + const unmount = registry.mount(activity); + yield* Effect.addFinalizer(() => Effect.sync(unmount)); + + const initial = yield* Effect.promise(() => executeAtomQuery(registry, activity)); + expect(AsyncResult.isSuccess(initial)).toBe(true); + if (!AsyncResult.isSuccess(initial)) { + return yield* Effect.die("activity did not load"); + } + expect(initial.value.comments[0]?.body).toBe("old comment"); + + const update = yield* Effect.promise(() => + atoms.updateComment.run(registry, { + environmentId: TARGET.environmentId, + input: { ...reference, commentId: "comment-1", kind: "issue-comment", body: "updated" }, + }), + ); + + expect(AsyncResult.isSuccess(update)).toBe(true); + expect( + (yield* AtomRegistry.getResult(registry, activity, { suspendOnWaiting: true })).comments[0] + ?.body, + ).toBe("updated"); + }), + ), +); diff --git a/packages/client-runtime/src/state/pullRequests.ts b/packages/client-runtime/src/state/pullRequests.ts index d4830fa197d4..22d44336ab0c 100644 --- a/packages/client-runtime/src/state/pullRequests.ts +++ b/packages/client-runtime/src/state/pullRequests.ts @@ -1,4 +1,9 @@ -import { WS_METHODS, type PullRequestDiffInput } from "@t3tools/contracts"; +import { + WS_METHODS, + type PullRequestDetail, + type PullRequestDiffInput, + type VcsStatusResult, +} from "@t3tools/contracts"; import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; @@ -21,6 +26,32 @@ export class EnvironmentHttpConnectionNotReadyError extends Data.TaggedError( "EnvironmentHttpConnectionNotReadyError", )<{ readonly message: string }> {} +/** Refresh a linked PR while its thread is visible so merges update the sidebar. */ +export function createLinkedPullRequestDetailAtomFamily( + runtime: Atom.AtomRuntime, +) { + return createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:pull-requests:linked-detail", + tag: WS_METHODS.pullRequestsDetail, + staleTimeMs: 15_000, + refreshIntervalMs: 30_000, + }); +} + +export function pullRequestDetailToVcsStatus( + detail: PullRequestDetail, +): NonNullable { + return { + number: detail.number, + title: detail.title, + url: detail.url, + baseRef: detail.baseBranch, + headRef: detail.headBranch, + state: detail.state, + updatedAt: detail.updatedAt, + }; +} + /** * Every read shells out to the GitHub CLI, so results are reused for a short while and * refreshed explicitly. Mutations run serially per environment: `gh` actions on the same @@ -34,6 +65,11 @@ export function createPullRequestEnvironmentAtoms( mode: "serial", key: ({ environmentId }: { readonly environmentId: string }) => environmentId, } as const; + const activity = createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:pull-requests:activity", + tag: WS_METHODS.pullRequestsActivity, + staleTimeMs: 15_000, + }); return { list: createEnvironmentRpcQueryAtomFamily(runtime, { label: "environment-data:pull-requests:list", @@ -56,11 +92,7 @@ export function createPullRequestEnvironmentAtoms( tag: WS_METHODS.pullRequestsDetail, staleTimeMs: 15_000, }), - activity: createEnvironmentRpcQueryAtomFamily(runtime, { - label: "environment-data:pull-requests:activity", - tag: WS_METHODS.pullRequestsActivity, - staleTimeMs: 15_000, - }), + activity, threadComments: createEnvironmentRpcCommand(runtime, { label: "environment-data:pull-requests:thread-comments", tag: WS_METHODS.pullRequestsThreadComments, @@ -129,6 +161,10 @@ export function createPullRequestEnvironmentAtoms( tag: WS_METHODS.pullRequestsUpdateComment, scheduler: commandScheduler, concurrency: serialPerEnvironment, + onSuccess: ({ environmentId, input: { projectId, repository, number } }, registry) => + Effect.sync(() => + registry.refresh(activity({ environmentId, input: { projectId, repository, number } })), + ), }), submitReview: createEnvironmentRpcCommand(runtime, { label: "environment-data:pull-requests:submit-review", diff --git a/packages/client-runtime/src/state/runtime.test.ts b/packages/client-runtime/src/state/runtime.test.ts index f36087ebf66a..745db2b9973f 100644 --- a/packages/client-runtime/src/state/runtime.test.ts +++ b/packages/client-runtime/src/state/runtime.test.ts @@ -6,12 +6,28 @@ import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; import * as Latch from "effect/Latch"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; +import { + AVAILABLE_CONNECTION_STATE, + ConnectionBlockedError, + ConnectionTransientError, + PrimaryConnectionTarget, + type PreparedConnection, + type SupervisorConnectionState, +} from "../connection/model.ts"; +import * as EnvironmentRegistry from "../connection/registry.ts"; +import * as EnvironmentSupervisor from "../connection/supervisor.ts"; +import { EnvironmentRpcUnavailableError } from "../rpc/client.ts"; +import type * as RpcSession from "../rpc/session.ts"; import { environmentRpcKey, createAtomCommandScheduler, + createEnvironmentQueryAtomFamily, createRuntimeCommand, scheduleAtomCommandEffect, executeAtomCommand, @@ -24,6 +40,98 @@ import { squashAtomCommandFailure, } from "./runtime.ts"; +const QUERY_ENVIRONMENT = new PrimaryConnectionTarget({ + environmentId: EnvironmentId.make("query-environment"), + label: "Query environment", + httpBaseUrl: "https://query.example.test", + wsBaseUrl: "wss://query.example.test", +}); + +const QUERY_RPC_SESSION = {} as RpcSession.RpcSession; + +class TestQueryError extends Schema.TaggedErrorClass()("TestQueryError", { + message: Schema.String, +}) {} + +const OFFLINE_QUERY_FAILURE = new ConnectionTransientError({ + reason: "transport", + detail: "Relay is unavailable.", +}); + +const BLOCKED_QUERY_FAILURE = new ConnectionBlockedError({ + reason: "permission", + detail: "Access denied.", +}); + +function queryConnectionState( + overrides: Partial = {}, +): SupervisorConnectionState { + return { + ...AVAILABLE_CONNECTION_STATE, + desired: true, + network: "online", + phase: "connected", + attempt: 1, + generation: 1, + ...overrides, + }; +} + +const makeEnvironmentQueryHarness = Effect.fn("TestEnvironmentQuery.makeHarness")(function* ( + execute: Effect.Effect, +) { + const supervisorState = yield* SubscriptionRef.make(queryConnectionState()); + const supervisorSession = yield* SubscriptionRef.make(Option.some(QUERY_RPC_SESSION)); + const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ + target: QUERY_ENVIRONMENT, + state: supervisorState, + session: supervisorSession, + prepared: yield* SubscriptionRef.make>(Option.none()), + connect: Effect.void, + disconnect: Effect.void, + retryNow: Effect.void, + } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); + const run: EnvironmentRegistry.EnvironmentRegistry["Service"]["run"] = (_environmentId, effect) => + Effect.provideService(effect, EnvironmentSupervisor.EnvironmentSupervisor, supervisor); + const followStream: EnvironmentRegistry.EnvironmentRegistry["Service"]["followStream"] = ( + _environmentId, + stream, + ) => Stream.provideService(stream, EnvironmentSupervisor.EnvironmentSupervisor, supervisor); + const environmentRegistry = EnvironmentRegistry.EnvironmentRegistry.of({ + run, + followStream, + stateChanges: () => SubscriptionRef.changes(supervisorState), + } as unknown as EnvironmentRegistry.EnvironmentRegistry["Service"]); + const runtime = Atom.runtime( + Layer.succeed(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry), + ); + const family = createEnvironmentQueryAtomFamily(runtime, { + label: "test.environment-query", + staleTimeMs: 60_000, + execute: () => execute, + }); + + return { + atom: family({ environmentId: QUERY_ENVIRONMENT.environmentId, input: undefined }), + supervisorSession, + supervisorState, + }; +}); + +const mountEnvironmentQuery = Effect.fn("TestEnvironmentQuery.mount")(function* ( + atom: Atom.Atom>, +) { + const registry = AtomRegistry.make(); + const unmount = registry.mount(atom); + yield* Effect.addFinalizer(() => + Effect.sync(() => { + unmount(); + registry.dispose(); + }), + ); + return registry; +}); + describe("settleAsyncResult", () => { it("preserves successful values and typed failures", async () => { const success = await settleAsyncResult(() => Promise.resolve(Exit.succeed("done"))); @@ -163,6 +271,283 @@ describe("environmentRpcKey", () => { }); }); +describe("environment query lifecycle", () => { + it.effect( + "retries an interrupted query without exposing a failure during session replacement", + () => + Effect.scoped( + Effect.gen(function* () { + const firstStarted = Latch.makeUnsafe(); + const failFirst = Latch.makeUnsafe(); + const firstSettled = Latch.makeUnsafe(); + const unavailable = new EnvironmentRpcUnavailableError({ + environmentId: QUERY_ENVIRONMENT.environmentId, + message: "Query environment is not connected.", + }); + let executions = 0; + const execute = Effect.suspend(() => { + executions += 1; + if (executions > 1) { + return Effect.succeed("recovered"); + } + firstStarted.openUnsafe(); + return failFirst.await.pipe( + Effect.andThen(Effect.fail(unavailable)), + Effect.ensuring( + Effect.sync(() => { + firstSettled.openUnsafe(); + }), + ), + ); + }); + const harness = yield* makeEnvironmentQueryHarness(execute); + const registry = AtomRegistry.make(); + const observed: Array> = []; + const unsubscribe = registry.subscribe( + harness.atom, + (result) => { + observed.push(result); + }, + { immediate: true }, + ); + yield* Effect.addFinalizer(() => + Effect.sync(() => { + unsubscribe(); + registry.dispose(); + }), + ); + + yield* firstStarted.await; + yield* SubscriptionRef.set(harness.supervisorSession, Option.none()); + yield* Effect.yieldNow; + failFirst.openUnsafe(); + yield* firstSettled.await; + yield* Effect.yieldNow; + + expect(observed.some(AsyncResult.isFailure)).toBe(false); + + yield* SubscriptionRef.set( + harness.supervisorState, + queryConnectionState({ phase: "connecting", stage: "preparing" }), + ); + yield* Effect.yieldNow; + yield* SubscriptionRef.set( + harness.supervisorState, + queryConnectionState({ + phase: "backoff", + stage: null, + lastFailure: new ConnectionTransientError({ + reason: "transport", + detail: "Relay session is reconnecting.", + }), + retryAt: 1, + }), + ); + yield* Effect.yieldNow; + + yield* SubscriptionRef.set(harness.supervisorSession, Option.some(QUERY_RPC_SESSION)); + yield* SubscriptionRef.set( + harness.supervisorState, + queryConnectionState({ generation: 2 }), + ); + expect( + yield* AtomRegistry.getResult(registry, harness.atom, { + suspendOnWaiting: true, + }), + ).toBe("recovered"); + }), + ), + ); + + it.effect.each([ + { + condition: "after a manual disconnect", + state: queryConnectionState({ + desired: false, + phase: "available", + stage: null, + attempt: 0, + }), + expectedFailure: null, + }, + { + condition: "while the environment is offline", + state: queryConnectionState({ + network: "offline", + phase: "offline", + stage: null, + lastFailure: OFFLINE_QUERY_FAILURE, + }), + expectedFailure: OFFLINE_QUERY_FAILURE, + }, + { + condition: "when connection recovery is blocked", + state: queryConnectionState({ + phase: "blocked", + stage: null, + lastFailure: BLOCKED_QUERY_FAILURE, + }), + expectedFailure: BLOCKED_QUERY_FAILURE, + }, + ] as const)("settles as unavailable $condition", ({ state, expectedFailure }) => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeEnvironmentQueryHarness(Effect.succeed("connected")); + const registry = yield* mountEnvironmentQuery(harness.atom); + + expect( + yield* AtomRegistry.getResult(registry, harness.atom, { + suspendOnWaiting: true, + }), + ).toBe("connected"); + + yield* SubscriptionRef.set(harness.supervisorState, state); + yield* Effect.yieldNow; + + const result = registry.get(harness.atom); + expect(AsyncResult.isFailure(result)).toBe(true); + expect(result.waiting).toBe(false); + if (AsyncResult.isFailure(result) && expectedFailure !== null) { + expect(Cause.squash(result.cause)).toBe(expectedFailure); + } + }), + ), + ); + + it.effect("keeps a genuine query failure settled while reconnecting", () => + Effect.scoped( + Effect.gen(function* () { + const expectedFailure = new TestQueryError({ message: "Query failed." }); + const firstStarted = Latch.makeUnsafe(); + const failFirst = Latch.makeUnsafe(); + const refreshStarted = Latch.makeUnsafe(); + const finishRefresh = Latch.makeUnsafe(); + let executions = 0; + const execute = Effect.suspend(() => { + executions += 1; + if (executions === 1) { + firstStarted.openUnsafe(); + return failFirst.await.pipe(Effect.andThen(Effect.fail(expectedFailure))); + } + refreshStarted.openUnsafe(); + return finishRefresh.await.pipe(Effect.as("recovered")); + }); + const harness = yield* makeEnvironmentQueryHarness(execute); + const registry = yield* mountEnvironmentQuery(harness.atom); + + yield* firstStarted.await; + failFirst.openUnsafe(); + const initial = yield* AtomRegistry.getResult(registry, harness.atom, { + suspendOnWaiting: true, + }).pipe(Effect.exit); + expect(Exit.isFailure(initial)).toBe(true); + if (Exit.isFailure(initial)) { + expect(Cause.squash(initial.cause)).toBe(expectedFailure); + } + + yield* SubscriptionRef.set( + harness.supervisorState, + queryConnectionState({ phase: "connecting", stage: "opening" }), + ); + yield* Effect.yieldNow; + + const refreshing = registry.get(harness.atom); + expect(AsyncResult.isFailure(refreshing)).toBe(true); + expect(refreshing.waiting).toBe(true); + if (AsyncResult.isFailure(refreshing)) { + expect(Cause.squash(refreshing.cause)).toBe(expectedFailure); + } + + yield* SubscriptionRef.set( + harness.supervisorState, + queryConnectionState({ generation: 2 }), + ); + yield* refreshStarted.await; + finishRefresh.openUnsafe(); + expect( + yield* AtomRegistry.getResult(registry, harness.atom, { + suspendOnWaiting: true, + }), + ).toBe("recovered"); + }), + ), + ); + + it.effect("retains the last successful value while reconnecting", () => + Effect.scoped( + Effect.gen(function* () { + const refreshStarted = Latch.makeUnsafe(); + const finishRefresh = Latch.makeUnsafe(); + let executions = 0; + const execute = Effect.suspend(() => { + executions += 1; + if (executions === 1) { + return Effect.succeed("cached"); + } + refreshStarted.openUnsafe(); + return finishRefresh.await.pipe(Effect.as("updated")); + }); + const harness = yield* makeEnvironmentQueryHarness(execute); + const registry = yield* mountEnvironmentQuery(harness.atom); + + expect( + yield* AtomRegistry.getResult(registry, harness.atom, { + suspendOnWaiting: true, + }), + ).toBe("cached"); + + yield* SubscriptionRef.set( + harness.supervisorState, + queryConnectionState({ phase: "connecting", stage: "opening" }), + ); + yield* Effect.yieldNow; + expect(registry.get(harness.atom)).toMatchObject({ + _tag: "Success", + value: "cached", + waiting: true, + }); + + yield* SubscriptionRef.set( + harness.supervisorState, + queryConnectionState({ + phase: "backoff", + stage: null, + lastFailure: new ConnectionTransientError({ + reason: "transport", + detail: "Retrying.", + }), + retryAt: 1, + }), + ); + yield* Effect.yieldNow; + expect(registry.get(harness.atom)).toMatchObject({ + _tag: "Success", + value: "cached", + waiting: true, + }); + + yield* SubscriptionRef.set( + harness.supervisorState, + queryConnectionState({ generation: 2 }), + ); + yield* refreshStarted.await; + expect(registry.get(harness.atom)).toMatchObject({ + _tag: "Success", + value: "cached", + waiting: true, + }); + + finishRefresh.openUnsafe(); + expect( + yield* AtomRegistry.getResult(registry, harness.atom, { + suspendOnWaiting: true, + }), + ).toBe("updated"); + }), + ), + ); +}); + describe("Atom.fn mutation semantics", () => { it.effect("interrupts the previous invocation when the same mutation atom is written again", () => Effect.gen(function* () { diff --git a/packages/client-runtime/src/state/runtime.ts b/packages/client-runtime/src/state/runtime.ts index 2404feb82b22..4b0fc330839e 100644 --- a/packages/client-runtime/src/state/runtime.ts +++ b/packages/client-runtime/src/state/runtime.ts @@ -3,21 +3,20 @@ import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Option from "effect/Option"; -import * as Result from "effect/Result"; import * as Stream from "effect/Stream"; import * as SubscriptionRef from "effect/SubscriptionRef"; import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; +import type { ConnectionAttemptError } from "../connection/model.ts"; import { EnvironmentNotRegisteredError, EnvironmentRegistry } from "../connection/registry.ts"; import { type EnvironmentRpcInput, type EnvironmentRpcStreamFailure, type EnvironmentRpcStreamValue, - type EnvironmentStreamCommandRpcTag, type EnvironmentSubscriptionRpcTag, type EnvironmentUnaryRpcTag, + EnvironmentRpcUnavailableError, request, - runStream, subscribe, } from "../rpc/client.ts"; import { EnvironmentSupervisor } from "../connection/supervisor.ts"; @@ -326,15 +325,34 @@ export async function executeAtomCommand( return result; } +export interface AtomQueryOptions extends AtomCommandOptions { + /** + * Force a fresh execution instead of accepting a value the atom already + * holds (e.g. an SWR-cached result within its stale window). Used by + * verification flows where a cached failure must not satisfy a retry. + */ + readonly refresh?: boolean; +} + export async function executeAtomQuery( registry: AtomRegistry.AtomRegistry, atom: Atom.Atom>, - options: AtomCommandOptions = {}, + options: AtomQueryOptions = {}, reporter: AtomCommandReporter = console, ): Promise> { const query = Effect.scoped( Effect.gen(function* () { yield* AtomRegistry.mount(registry, atom); + if (options.refresh) { + yield* Effect.sync(() => { + // Only a settled value can be a leftover from an earlier read; a + // computation that mounting just started is already fresh. + const current = registry.get(atom); + if (current._tag !== "Initial" && !current.waiting) { + registry.refresh(atom); + } + }); + } return yield* AtomRegistry.getResult(registry, atom, { suspendOnWaiting: true, }); @@ -484,7 +502,7 @@ export function createEnvironmentQueryAtomFamily( readonly environmentId: EnvironmentIdType; readonly input: Input; }) => Atom.Atom> { - const rpcGenerationAtom = Atom.family((environmentId: EnvironmentIdType) => + const connectionAtom = Atom.family((environmentId: EnvironmentIdType) => runtime.atom( followStreamInEnvironment( environmentId, @@ -492,11 +510,7 @@ export function createEnvironmentQueryAtomFamily( EnvironmentSupervisor.pipe( Effect.map((supervisor) => SubscriptionRef.changes(supervisor.state).pipe( - Stream.filterMap((state) => - state.phase === "connected" ? Result.succeed(state.generation) : Result.failVoid, - ), - Stream.changes, - Stream.map((generation) => generation), + Stream.zipLatest(SubscriptionRef.changes(supervisor.session)), ), ), ), @@ -509,14 +523,40 @@ export function createEnvironmentQueryAtomFamily( const target = parseEnvironmentRpcKey(key); const idleTtlMs = options.idleTtlMs ?? 5 * 60_000; const queryAtom = runtime - .atom((get) => { - const generation = Option.getOrNull( - AsyncResult.value(get(rpcGenerationAtom(target.environmentId))), + .atom< + A, + E | ConnectionAttemptError | EnvironmentNotRegisteredError | EnvironmentRpcUnavailableError + >((get) => { + const connection = Option.getOrNull( + AsyncResult.value(get(connectionAtom(target.environmentId))), ); - if (generation === null) { + if (connection === null) { return Effect.never; } - return runInEnvironment(target.environmentId, options.execute(target.input)); + const [connectionState, session] = connection; + switch (connectionState.phase) { + case "connected": + return Option.isSome(session) + ? runInEnvironment(target.environmentId, options.execute(target.input)) + : Effect.never; + case "connecting": + case "backoff": + return Effect.never; + case "available": + case "offline": + case "blocked": + if (connectionState.lastFailure !== null) { + return Effect.fail(connectionState.lastFailure); + } + return Effect.fail( + new EnvironmentRpcUnavailableError({ + environmentId: target.environmentId, + message: `Environment ${target.environmentId} is ${ + connectionState.phase === "available" ? "not connected" : connectionState.phase + }.`, + }), + ); + } }) .pipe( Atom.swr({ @@ -567,29 +607,6 @@ export function createEnvironmentCommand( }); } -function createEnvironmentStreamCommand( - runtime: Atom.AtomRuntime, - options: { - readonly label: string; - readonly execute: (input: Input) => Stream.Stream; - readonly scheduler?: AtomCommandScheduler; - readonly concurrency?: AtomCommandConcurrency<{ - readonly environmentId: EnvironmentIdType; - readonly input: Input; - }>; - }, -) { - return createRuntimeStreamCommand(runtime, { - label: options.label, - ...(options.scheduler === undefined ? {} : { scheduler: options.scheduler }), - ...(options.concurrency === undefined ? {} : { concurrency: options.concurrency }), - execute: (target) => - runStreamInEnvironment(target.environmentId, options.execute(target.input)).pipe( - Stream.withSpan(options.label), - ), - }); -} - export function createEnvironmentRpcQueryAtomFamily( runtime: Atom.AtomRuntime, options: { @@ -685,27 +702,3 @@ export function createEnvironmentRpcCommand( - runtime: Atom.AtomRuntime, - options: { - readonly label: string; - readonly tag: TTag; - readonly scheduler?: AtomCommandScheduler; - readonly concurrency?: AtomCommandConcurrency<{ - readonly environmentId: EnvironmentIdType; - readonly input: EnvironmentRpcInput; - }>; - }, -) { - return createEnvironmentStreamCommand(runtime, { - label: options.label, - ...(options.scheduler === undefined ? {} : { scheduler: options.scheduler }), - ...(options.concurrency === undefined ? {} : { concurrency: options.concurrency }), - execute: (input: EnvironmentRpcInput) => runStream(options.tag, input), - }); -} diff --git a/packages/client-runtime/src/state/server.test.ts b/packages/client-runtime/src/state/server.test.ts index 8ee312f61b21..ea170c22e830 100644 --- a/packages/client-runtime/src/state/server.test.ts +++ b/packages/client-runtime/src/state/server.test.ts @@ -30,7 +30,6 @@ import * as Persistence from "../platform/persistence.ts"; import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; import type { RpcSession } from "../rpc/session.ts"; import { - applyServerConfigProjection, makeEnvironmentServerConfigState, isLegacyUpdateHandoffLoss, matchesServerUpdateReadyEvent, @@ -42,6 +41,7 @@ import { serverUpdateStateForServerVersion, validateServerUpdateReadyEvent, } from "./server.ts"; +import { applyServerConfigProjection } from "./serverConfigProjection.ts"; const CONFIG = { availableEditors: [], @@ -51,6 +51,9 @@ const CONFIG = { observability: null, providers: [], settings: {}, + // Capabilities drive version-skew behaviour in the projection, so the + // fixture carries them rather than leaving the field absent. + environment: { capabilities: { environmentThemes: true } }, } as unknown as ServerConfig; const snapshotEvent = (config: ServerConfig): ServerConfigStreamEvent => ({ @@ -70,6 +73,7 @@ function session(client: WsRpcProtocolClient): RpcSession { return { client, initialConfig: Effect.succeed(CONFIG), + subscribeServerConfig: (input) => client.subscribeServerConfig(input), ready: Effect.void, probe: Effect.void, closed: Effect.never, @@ -306,6 +310,78 @@ describe("server state projection", () => { expect(result.latestEvent.type).toBe("settingsUpdated"); }); + it("carries published environment themes in and out of the projected snapshot", () => { + const snapshot = applyServerConfigProjection(Option.none(), { + version: 1, + type: "snapshot", + config: CONFIG, + }); + const themes = [ + { + id: "nightfall", + name: "Nightfall", + appearance: "dark", + canvas: "#1a1b26", + accent: "#7aa2f7", + }, + ] as const; + + const published = applyServerConfigProjection(snapshot, { + version: 1, + type: "environmentThemesUpdated", + payload: { themes }, + }); + expect(Option.getOrThrow(published).config.environmentThemes).toEqual(themes); + + // A machine that stops publishing has to clear the palettes, not freeze + // clients on the last set it sent. + const unpublished = applyServerConfigProjection(published, { + version: 1, + type: "environmentThemesUpdated", + payload: { themes: [] }, + }); + expect(Option.getOrThrow(unpublished).config.environmentThemes).toBeUndefined(); + }); + + // A snapshot never carries published themes, so taking it wholesale would + // clear them on every reconnect and repaint anyone wearing one. + it("keeps published themes across a reconnect snapshot", () => { + const themes = [ + { + id: "nightfall", + name: "Nightfall", + appearance: "dark", + canvas: "#1a1b26", + accent: "#7aa2f7", + }, + ] as const; + + const withThemes = applyServerConfigProjection( + applyServerConfigProjection(Option.none(), { version: 1, type: "snapshot", config: CONFIG }), + { version: 1, type: "environmentThemesUpdated", payload: { themes } }, + ); + expect(Option.getOrThrow(withThemes).config.environmentThemes).toEqual(themes); + + const afterReconnect = applyServerConfigProjection(withThemes, { + version: 1, + type: "snapshot", + config: CONFIG, + }); + expect(Option.getOrThrow(afterReconnect).config.environmentThemes).toEqual(themes); + + // A server that predates the feature never sends another theme event, so + // carrying the set forward would leave a palette nothing can update. + const downgraded = applyServerConfigProjection(withThemes, { + version: 1, + type: "snapshot", + config: { + ...CONFIG, + environment: { capabilities: {} }, + } as unknown as ServerConfig, + }); + expect(Option.getOrThrow(downgraded).config.environmentThemes).toBeUndefined(); + }); + it("retains welcome when a ready event follows in the same stream chunk", () => { const welcome = { environment: {} as ServerLifecycleWelcomePayload["environment"], diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index 2fef689a9bbb..8ce8b46f058a 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -41,6 +41,14 @@ import { type EnvironmentRpcInput, } from "../rpc/client.ts"; import { followStreamInEnvironment } from "./runtime.ts"; +import { + applyServerConfigProjection, + type ServerConfigProjection, + withoutEnvironmentThemes, +} from "./serverConfigProjection.ts"; + +// Exported server state includes this type in its inferred public return type. +export type { ServerConfigProjection } from "./serverConfigProjection.ts"; export type ServerUpdateStage = "downloading" | "installing" | "resuming"; @@ -262,75 +270,14 @@ export function resolveServerUpdateProgressResult( return Effect.fail(new ServerUpdateProgressIncompleteError({ targetVersion })); } -export interface ServerConfigProjection { - readonly config: ServerConfig; - readonly latestEvent: ServerConfigStreamEvent; - readonly source: "cache" | "live"; -} - -export function applyServerConfigProjection( - current: Option.Option, - event: ServerConfigStreamEvent, -): Option.Option { - switch (event.type) { - case "snapshot": - return Option.some({ - config: event.config, - latestEvent: event, - source: "live", - }); - case "keybindingsUpdated": - return Option.map(current, (projection) => ({ - config: { - ...projection.config, - keybindings: event.payload.keybindings, - issues: event.payload.issues, - }, - latestEvent: event, - source: "live", - })); - case "providerStatuses": - return Option.map(current, (projection) => ({ - config: { - ...projection.config, - providers: event.payload.providers, - }, - latestEvent: event, - source: "live", - })); - case "settingsUpdated": - return Option.map(current, (projection) => ({ - config: { - ...projection.config, - settings: event.payload.settings, - }, - latestEvent: event, - source: "live", - })); - } -} - -export function projectServerConfig( - current: Option.Option, - event: ServerConfigStreamEvent, -): readonly [Option.Option, ReadonlyArray] { - const next = applyServerConfigProjection(current, event); - return [next, Option.toArray(next)]; -} - const cachedConfigSnapshotEvent = (config: ServerConfig): ServerConfigStreamEvent => ({ version: 1, type: "snapshot", config, }); -/** - * Keeps a complete server configuration available during reconnects. Server - * config carries the provider/model catalogue used by task creation, so it is - * useful—and safe—to retain after a transport session ends. - */ export const makeEnvironmentServerConfigState = Effect.fn("EnvironmentServerConfigState.make")( - function* () { + function* (environmentThemes?: boolean) { const supervisor = yield* EnvironmentSupervisor; const cache = yield* EnvironmentCacheStore; const environmentId = supervisor.target.environmentId; @@ -346,9 +293,11 @@ export const makeEnvironmentServerConfigState = Effect.fn("EnvironmentServerConf ), ); const state = yield* SubscriptionRef.make>( - Option.map(cachedConfig, (config) => ({ - config, - latestEvent: cachedConfigSnapshotEvent(config), + // Stripped on load as well as on save: a cache written by an earlier + // build can still carry published themes. + Option.map(cachedConfig, (cached) => ({ + config: withoutEnvironmentThemes(cached), + latestEvent: cachedConfigSnapshotEvent(withoutEnvironmentThemes(cached)), source: "cache" as const, })), ); @@ -358,7 +307,7 @@ export const makeEnvironmentServerConfigState = Effect.fn("EnvironmentServerConf const persist = Effect.fn("EnvironmentServerConfigState.persist")(function* ( config: ServerConfig, ) { - return yield* cache.saveServerConfig(environmentId, config).pipe( + return yield* cache.saveServerConfig(environmentId, withoutEnvironmentThemes(config)).pipe( Effect.as(true), Effect.catch((error) => Effect.logWarning("Could not persist cached server configuration.").pipe( @@ -389,7 +338,10 @@ export const makeEnvironmentServerConfigState = Effect.fn("EnvironmentServerConf Effect.forkScoped, ); - yield* subscribe(WS_METHODS.subscribeServerConfig, {}).pipe( + yield* subscribe( + WS_METHODS.subscribeServerConfig, + environmentThemes === true ? { environmentThemes: true } : {}, + ).pipe( Stream.runForEach((event) => Effect.gen(function* () { const next = applyServerConfigProjection(yield* SubscriptionRef.get(state), event); @@ -419,11 +371,14 @@ export const makeEnvironmentServerConfigState = Effect.fn("EnvironmentServerConf }, ); -export function serverConfigStateChanges(environmentId: EnvironmentId) { +export function serverConfigStateChanges( + environmentId: EnvironmentId, + environmentThemes?: boolean, +) { return followStreamInEnvironment( environmentId, Stream.unwrap( - makeEnvironmentServerConfigState().pipe( + makeEnvironmentServerConfigState(environmentThemes).pipe( Effect.map((state) => SubscriptionRef.changes(state).pipe( Stream.filterMap((projection) => @@ -476,6 +431,12 @@ export function createServerEnvironmentAtoms( readonly initialConfigValueAtom: ( environmentId: EnvironmentId, ) => Atom.Atom; + /** + * Whether this surface renders themes the environment publishes. Mobile + * keeps its own appearance settings, so it neither asks for the stream nor + * receives the payload. + */ + readonly environmentThemes?: boolean; }, ) { const configScheduler = createAtomCommandScheduler(); @@ -487,7 +448,7 @@ export function createServerEnvironmentAtoms( }; const configProjectionFamily = Atom.family((environmentId: EnvironmentId) => runtime - .atom(serverConfigStateChanges(environmentId)) + .atom(serverConfigStateChanges(environmentId, options.environmentThemes)) .pipe( Atom.setIdleTTL(5 * 60_000), Atom.withLabel(`environment-data:server:config-projection:${environmentId}`), diff --git a/packages/client-runtime/src/state/serverConfigProjection.ts b/packages/client-runtime/src/state/serverConfigProjection.ts new file mode 100644 index 000000000000..6f4a812cf7e9 --- /dev/null +++ b/packages/client-runtime/src/state/serverConfigProjection.ts @@ -0,0 +1,79 @@ +import type { ServerConfig, ServerConfigStreamEvent } from "@t3tools/contracts"; +import * as Option from "effect/Option"; + +export interface ServerConfigProjection { + readonly config: ServerConfig; + readonly latestEvent: ServerConfigStreamEvent; + readonly source: "cache" | "live"; +} + +/** + * Cached config keeps the provider and model catalog available across reconnects. + * Published themes are current machine state, so a cache could restore themes + * that the machine no longer publishes. Replay sends themes as a separate event. + */ +export function withoutEnvironmentThemes(config: ServerConfig): ServerConfig { + if (config.environmentThemes === undefined) return config; + const { environmentThemes: _ephemeral, ...rest } = config; + return rest; +} + +export function applyServerConfigProjection( + current: Option.Option, + event: ServerConfigStreamEvent, +): Option.Option { + switch (event.type) { + case "snapshot": { + // Wire snapshots never contain published themes. Keep the previous set + // until a capable server sends its authoritative theme event. A legacy + // server cannot send a later removal, so a downgrade must clear the set. + const carried = + event.config.environment.capabilities.environmentThemes === true && Option.isSome(current) + ? current.value.config.environmentThemes + : undefined; + return Option.some({ + config: + carried === undefined ? event.config : { ...event.config, environmentThemes: carried }, + latestEvent: event, + source: "live" as const, + }); + } + case "keybindingsUpdated": + return Option.map(current, (projection) => ({ + config: { + ...projection.config, + keybindings: event.payload.keybindings, + issues: event.payload.issues, + }, + latestEvent: event, + source: "live", + })); + case "providerStatuses": + return Option.map(current, (projection) => ({ + config: { + ...projection.config, + providers: event.payload.providers, + }, + latestEvent: event, + source: "live", + })); + case "settingsUpdated": + return Option.map(current, (projection) => ({ + config: { + ...projection.config, + settings: event.payload.settings, + }, + latestEvent: event, + source: "live", + })); + case "environmentThemesUpdated": + return Option.map(current, (projection) => ({ + config: { + ...projection.config, + environmentThemes: event.payload.themes.length > 0 ? event.payload.themes : undefined, + }, + latestEvent: event, + source: "live", + })); + } +} diff --git a/packages/client-runtime/src/state/session.test.ts b/packages/client-runtime/src/state/session.test.ts deleted file mode 100644 index fe1dcdbe3f2b..000000000000 --- a/packages/client-runtime/src/state/session.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { describe, expect, it } from "@effect/vitest"; -import * as Effect from "effect/Effect"; -import * as Option from "effect/Option"; -import * as Schema from "effect/Schema"; - -import { initialConfigOption } from "./session.ts"; - -class TestConfigError extends Schema.TaggedErrorClass()("TestConfigError", { - message: Schema.String, -}) {} - -describe("environment session state", () => { - it.effect("turns an initial config failure into an empty value", () => - Effect.gen(function* () { - const result = yield* initialConfigOption( - Effect.fail(new TestConfigError({ message: "temporary failure" })), - ); - expect(Option.isNone(result)).toBe(true); - }), - ); -}); diff --git a/packages/client-runtime/src/state/session.ts b/packages/client-runtime/src/state/session.ts index 31fd297da3f0..43adb980f3ad 100644 --- a/packages/client-runtime/src/state/session.ts +++ b/packages/client-runtime/src/state/session.ts @@ -16,7 +16,7 @@ import { executeEnvironmentHttpRequest, makeEnvironmentHttpApiClient } from "../ import { buildEnvironmentAuthHeaders, withEnvironmentCredentials } from "./environmentHttpAuth.ts"; import { followStreamInEnvironment } from "./runtime.ts"; -export function initialConfigOption( +function initialConfigOption( initialConfig: Effect.Effect, ): Effect.Effect> { return initialConfig.pipe( diff --git a/packages/client-runtime/src/state/shell-sync.test.ts b/packages/client-runtime/src/state/shell-sync.test.ts index 40e9bd80dc5b..1c0d838026fb 100644 --- a/packages/client-runtime/src/state/shell-sync.test.ts +++ b/packages/client-runtime/src/state/shell-sync.test.ts @@ -51,6 +51,7 @@ function session(client: WsRpcProtocolClient): RpcSession.RpcSession { return { client, initialConfig: Effect.succeed({ shellResumeCompletionMarker: true } as never), + subscribeServerConfig: (input) => client.subscribeServerConfig(input), ready: Effect.void, probe: Effect.void, closed: Effect.never, diff --git a/packages/client-runtime/src/state/sourceControl.test.ts b/packages/client-runtime/src/state/sourceControl.test.ts index 393be8e3227d..33c566bf82b6 100644 --- a/packages/client-runtime/src/state/sourceControl.test.ts +++ b/packages/client-runtime/src/state/sourceControl.test.ts @@ -50,6 +50,7 @@ function session(client: WsRpcProtocolClient): RpcSession { return { client, initialConfig: Effect.never, + subscribeServerConfig: (input) => client.subscribeServerConfig(input), ready: Effect.void, probe: Effect.void, closed: Effect.never, diff --git a/packages/client-runtime/src/state/subagentRuntime.test.ts b/packages/client-runtime/src/state/subagentRuntime.test.ts index ff0aea7c8a51..f87531f15808 100644 --- a/packages/client-runtime/src/state/subagentRuntime.test.ts +++ b/packages/client-runtime/src/state/subagentRuntime.test.ts @@ -603,6 +603,43 @@ describe("model and effort attribution", () => { expect(agents[0]!.effort).toBe("high"); }); + it("applies metadata-only updates without changing the current status", () => { + const waitingRows = [ + activity("task.updated", { + taskId: "task-metadata", + title: "Check metadata", + status: "waiting", + }), + activity("task.updated", { + taskId: "task-metadata", + model: "gpt-5.6-sol", + effort: "high", + }), + ]; + const waitingAgent = fold(waitingRows)[0]!; + expect(waitingAgent.status).toBe("waiting"); + expect(formatSubagentModelLabel(waitingAgent.model, waitingAgent.effort)).toBe( + "gpt-5.6-sol · high", + ); + + const idleRows = [ + ...waitingRows, + activity("task.updated", { taskId: "task-metadata", status: "idle" }), + activity("task.updated", { taskId: "task-metadata", model: "gpt-5.6-sol" }), + ]; + expect(fold(idleRows)[0]!.status).toBe("idle"); + + const completedAgent = fold([ + ...idleRows, + activity("task.progress", { taskId: "task-metadata", typedUsage: { totalTokens: 42 } }), + activity("task.completed", { taskId: "task-metadata", status: "completed" }), + activity("task.updated", { taskId: "task-metadata", effort: "high" }), + ])[0]!; + expect(completedAgent.status).toBe("completed"); + expect(completedAgent.model).toBe("gpt-5.6-sol"); + expect(completedAgent.effort).toBe("high"); + }); + it("formatSubagentModelLabel compacts ids and appends effort", () => { expect(formatSubagentModelLabel("claude-sonnet-5[1m]", "high")).toBe("sonnet-5[1m] · high"); expect(formatSubagentModelLabel("claude-opus-4-20250514", null)).toBe("opus-4"); diff --git a/packages/client-runtime/src/state/threadReducer.test.ts b/packages/client-runtime/src/state/threadReducer.test.ts index 8b2479c7a349..2042b2168c88 100644 --- a/packages/client-runtime/src/state/threadReducer.test.ts +++ b/packages/client-runtime/src/state/threadReducer.test.ts @@ -305,6 +305,51 @@ describe("applyThreadDetailEvent", () => { expect(result.thread.modelSelection).toEqual(baseThread.modelSelection); } }); + + it("sets and clears a linked pull request", () => { + const linkedPullRequest = { + projectId: ProjectId.make("project-1"), + repository: "pingdotgg/t3code", + number: 42, + url: "https://github.com/pingdotgg/t3code/pull/42", + }; + const linked = applyThreadDetailEvent(baseThread, { + ...baseEventFields, + sequence: 5, + occurredAt: "2026-04-01T05:00:00.000Z", + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-1"), + type: "thread.meta-updated", + payload: { + threadId: ThreadId.make("thread-1"), + linkedPullRequest, + updatedAt: "2026-04-01T05:00:00.000Z", + }, + }); + + expect(linked.kind).toBe("updated"); + if (linked.kind !== "updated") return; + expect(linked.thread.linkedPullRequest).toEqual(linkedPullRequest); + + const cleared = applyThreadDetailEvent(linked.thread, { + ...baseEventFields, + sequence: 6, + occurredAt: "2026-04-01T06:00:00.000Z", + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-1"), + type: "thread.meta-updated", + payload: { + threadId: ThreadId.make("thread-1"), + linkedPullRequest: null, + updatedAt: "2026-04-01T06:00:00.000Z", + }, + }); + + expect(cleared.kind).toBe("updated"); + if (cleared.kind === "updated") { + expect(cleared.thread.linkedPullRequest).toBeNull(); + } + }); }); describe("thread.message-sent", () => { diff --git a/packages/client-runtime/src/state/threadReducer.ts b/packages/client-runtime/src/state/threadReducer.ts index 970fd94b1a16..10d5898c8fe8 100644 --- a/packages/client-runtime/src/state/threadReducer.ts +++ b/packages/client-runtime/src/state/threadReducer.ts @@ -92,6 +92,7 @@ export function applyThreadDetailEvent( archivedAt: null, settledOverride: null, settledAt: null, + unsettledAt: null, snoozedUntil: null, snoozedAt: null, deletedAt: null, @@ -130,6 +131,7 @@ export function applyThreadDetailEvent( ...thread, settledOverride: "settled", settledAt: event.payload.settledAt, + unsettledAt: null, updatedAt: event.payload.updatedAt, }, }; @@ -141,6 +143,12 @@ export function applyThreadDetailEvent( ...thread, settledOverride: event.payload.reason === "user" ? "active" : null, settledAt: null, + // A thread already pinned active keeps its re-entry stamp: the + // activity reset that clears the pin must not reorder the list. + unsettledAt: + thread.settledOverride === "active" + ? (thread.unsettledAt ?? null) + : event.payload.updatedAt, updatedAt: event.payload.updatedAt, }, }; @@ -218,6 +226,9 @@ export function applyThreadDetailEvent( ...(event.payload.worktreePath !== undefined ? { worktreePath: event.payload.worktreePath } : {}), + ...(event.payload.linkedPullRequest !== undefined + ? { linkedPullRequest: event.payload.linkedPullRequest } + : {}), updatedAt: event.payload.updatedAt, }, }; diff --git a/packages/client-runtime/src/state/threadSettled.test.ts b/packages/client-runtime/src/state/threadSettled.test.ts deleted file mode 100644 index 06a8bb32c793..000000000000 --- a/packages/client-runtime/src/state/threadSettled.test.ts +++ /dev/null @@ -1,587 +0,0 @@ -import { - ProjectId, - ProviderInstanceId, - ThreadId, - TurnId, - type OrchestrationThreadShell, -} from "@t3tools/contracts"; -import { describe, expect, it } from "vite-plus/test"; - -import { - canSettle, - changeRequestAutoSettles, - effectiveSettled, - hasQueuedTurnStart, - threadLastActivityAt, - type ChangeRequestStateLike, -} from "./threadSettled.ts"; - -const NOW = "2026-04-10T00:00:00.000Z"; -const FRESH = "2026-04-09T00:00:00.000Z"; -const STALE = "2026-04-06T23:59:59.999Z"; - -describe("changeRequestAutoSettles", () => { - it.each([ - ["open", true, false], - ["merged", true, true], - ["merged", false, false], - ["closed", false, true], - [null, false, false], - ] as const)("state=%s autoSettleOnMerge=%s returns %s", (state, autoSettleOnMerge, expected) => { - expect(changeRequestAutoSettles(state === null ? null : { state }, { autoSettleOnMerge })).toBe( - expected, - ); - }); - - const THREAD_CREATED_AT = "2026-04-01T00:00:00.000Z"; - const idleThread = { - createdAt: THREAD_CREATED_AT, - latestUserMessageAt: null, - latestTurn: null, - }; - - it("ignores a terminal change request last touched before the thread existed", () => { - for (const state of ["merged", "closed"] as const) { - expect( - changeRequestAutoSettles( - { state, updatedAt: "2026-03-31T23:59:59.999Z" }, - { thread: idleThread }, - ), - ).toBe(false); - } - }); - - it("settles on a terminal change request touched at or after the thread's latest event", () => { - for (const updatedAt of [THREAD_CREATED_AT, "2026-04-02T00:00:00.000Z"]) { - expect(changeRequestAutoSettles({ state: "merged", updatedAt }, { thread: idleThread })).toBe( - true, - ); - } - }); - - it("never re-settles a thread revived after the merge", () => { - // Settling on a merge happens once: a user message newer than the PR's - // last activity means the conversation outlived the PR. - const revived = { - createdAt: THREAD_CREATED_AT, - latestUserMessageAt: "2026-04-05T00:00:00.000Z", - latestTurn: null, - }; - expect( - changeRequestAutoSettles( - { state: "merged", updatedAt: "2026-04-03T00:00:00.000Z" }, - { thread: revived }, - ), - ).toBe(false); - // A merge landing after the revival still settles. - expect( - changeRequestAutoSettles( - { state: "merged", updatedAt: "2026-04-06T00:00:00.000Z" }, - { thread: revived }, - ), - ).toBe(true); - }); - - it("still settles when the merge lands during an in-flight turn", () => { - // Anchor is user-initiated activity only: the agent finishing a turn - // after the merge must not block the settle the merge earned. - const midTurnMerge = { - createdAt: THREAD_CREATED_AT, - latestUserMessageAt: "2026-04-02T00:00:00.000Z", - latestTurn: { - turnId: TurnId.make("turn-mid"), - state: "completed" as const, - requestedAt: "2026-04-02T00:00:00.000Z", - startedAt: "2026-04-02T00:00:05.000Z", - completedAt: "2026-04-02T00:20:00.000Z", - assistantMessageId: null, - }, - }; - expect( - changeRequestAutoSettles( - { state: "merged", updatedAt: "2026-04-02T00:10:00.000Z" }, - { thread: midTurnMerge }, - ), - ).toBe(true); - }); - - it("falls back to settling when either timestamp is missing or malformed", () => { - expect(changeRequestAutoSettles({ state: "merged" }, { thread: idleThread })).toBe(true); - expect( - changeRequestAutoSettles({ state: "merged", updatedAt: null }, { thread: idleThread }), - ).toBe(true); - expect( - changeRequestAutoSettles({ state: "merged", updatedAt: "2026-03-01T00:00:00.000Z" }, {}), - ).toBe(true); - expect( - changeRequestAutoSettles( - { state: "merged", updatedAt: "not-a-date" }, - { thread: idleThread }, - ), - ).toBe(true); - }); -}); - -function makeShell(input: { - readonly settledOverride?: "settled" | "active" | null; - readonly activityAt: string | null; - readonly sessionStatus?: "starting" | "running"; - readonly pending?: "approval" | "user-input"; -}): OrchestrationThreadShell { - const threadId = ThreadId.make("thread-1"); - return { - id: threadId, - projectId: ProjectId.make("project-1"), - title: "Thread", - modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: null, - latestTurn: - input.activityAt === null - ? null - : { - turnId: TurnId.make("turn-1"), - state: "completed", - requestedAt: input.activityAt, - startedAt: null, - completedAt: null, - assistantMessageId: null, - }, - createdAt: "2026-04-01T00:00:00.000Z", - updatedAt: NOW, - archivedAt: null, - settledOverride: input.settledOverride ?? null, - settledAt: input.settledOverride === "settled" ? NOW : null, - session: - input.sessionStatus === undefined - ? null - : { - threadId, - status: input.sessionStatus, - providerName: "Codex", - runtimeMode: "full-access", - activeTurnId: null, - lastError: null, - updatedAt: NOW, - }, - latestUserMessageAt: null, - hasPendingApprovals: input.pending === "approval", - hasPendingUserInput: input.pending === "user-input", - hasActionableProposedPlan: false, - }; -} - -describe("threadLastActivityAt", () => { - it("returns the latest real user or turn activity and ignores thread/session updates", () => { - const shell = makeShell({ activityAt: null, sessionStatus: "running" }); - const withActivity: OrchestrationThreadShell = { - ...shell, - latestUserMessageAt: "2026-04-04T00:00:00.000Z", - latestTurn: { - turnId: TurnId.make("turn-1"), - state: "completed", - requestedAt: "2026-04-03T00:00:00.000Z", - startedAt: "2026-04-05T00:00:00.000Z", - completedAt: "2026-04-06T00:00:00.000Z", - assistantMessageId: null, - }, - }; - - expect(threadLastActivityAt(withActivity)).toBe("2026-04-06T00:00:00.000Z"); - expect(threadLastActivityAt(shell)).toBeNull(); - }); -}); - -describe("effectiveSettled", () => { - const overrideCases = [null, "settled", "active"] as const; - const changeRequestStates = [undefined, "open", "merged"] as const; - const inactivityCases = [ - ["fresh", FRESH], - ["stale", STALE], - ["no-activity", null], - ] as const; - const runningCases = [false, true] as const; - const pendingCases = [undefined, "approval", "user-input"] as const; - const truthTable = overrideCases.flatMap((settledOverride) => - changeRequestStates.flatMap((changeRequestState) => - inactivityCases.flatMap(([inactivity, activityAt]) => - runningCases.flatMap((running) => - pendingCases.map((pending) => ({ - settledOverride, - changeRequestState, - inactivity, - activityAt, - running, - pending, - // Settled iff nothing blocks (pending work / live session) AND - // the override says settled, or (with no override) a merged PR - // or staleness auto-settles. The "active" pin suppresses both - // auto signals, and an open PR suppresses the inactivity path: - // a thread with a PR out for review is never done, however quiet. - expected: - pending === undefined && - !running && - (settledOverride === "settled" || - (settledOverride === null && - (changeRequestState === "merged" || - (changeRequestState !== "open" && inactivity === "stale")))), - })), - ), - ), - ), - ); - - it.each(truthTable)( - "override=$settledOverride pr=$changeRequestState inactivity=$inactivity running=$running pending=$pending", - ({ settledOverride, changeRequestState, activityAt, running, pending, expected }) => { - const shell = makeShell({ - settledOverride, - activityAt, - ...(running ? { sessionStatus: "running" as const } : {}), - ...(pending === undefined ? {} : { pending }), - }); - const changeRequestOptions = - changeRequestState === undefined - ? {} - : { changeRequest: { state: changeRequestState as ChangeRequestStateLike } }; - - expect( - effectiveSettled(shell, { - now: NOW, - autoSettleAfterDays: 3, - ...changeRequestOptions, - }), - ).toBe(expected); - }, - ); - - it("treats closed change requests like merged ones", () => { - const shell = makeShell({ activityAt: null }); - expect( - effectiveSettled(shell, { - now: NOW, - autoSettleAfterDays: null, - changeRequest: { state: "closed" }, - }), - ).toBe(true); - }); - - it("settles immediately when a change request merges or closes", () => { - const recentlyActive = makeShell({ activityAt: "2026-04-09T23:59:59.999Z" }); - for (const changeRequestState of ["merged", "closed"] as const) { - expect( - effectiveSettled(recentlyActive, { - now: NOW, - autoSettleAfterDays: null, - changeRequest: { state: changeRequestState }, - }), - ).toBe(true); - } - }); - - it("ignores a change request that merged before the thread's latest event", () => { - // A new thread started at a worktree root inherits the branch's old - // merged PR, and a revived thread outlives its merge; neither settles - // the live conversation. - const fresh = makeShell({ activityAt: FRESH }); - for (const state of ["merged", "closed"] as const) { - expect( - effectiveSettled(fresh, { - now: NOW, - autoSettleAfterDays: null, - changeRequest: { state, updatedAt: "2026-03-20T00:00:00.000Z" }, - }), - ).toBe(false); - } - // A merge during the thread's life still settles it. - expect( - effectiveSettled(fresh, { - now: NOW, - autoSettleAfterDays: null, - changeRequest: { state: "merged", updatedAt: "2026-04-09T00:00:00.000Z" }, - }), - ).toBe(true); - }); - - it("can keep a merged change request active", () => { - const recentlyActive = makeShell({ activityAt: "2026-04-09T23:59:59.999Z" }); - expect( - effectiveSettled(recentlyActive, { - now: NOW, - autoSettleAfterDays: null, - autoSettleOnMerge: false, - changeRequest: { state: "merged" }, - }), - ).toBe(false); - - expect( - effectiveSettled(recentlyActive, { - now: NOW, - autoSettleAfterDays: null, - autoSettleOnMerge: false, - changeRequest: { state: "closed" }, - }), - ).toBe(true); - }); - - it("never auto-settles a stale thread with an open change request", () => { - const stale = makeShell({ activityAt: STALE }); - expect( - effectiveSettled(stale, { - now: NOW, - autoSettleAfterDays: 3, - changeRequest: { state: "open" }, - }), - ).toBe(false); - // An explicit user settle still wins: open PR only blocks the auto path. - const settled = makeShell({ settledOverride: "settled", activityAt: STALE }); - expect( - effectiveSettled(settled, { - now: NOW, - autoSettleAfterDays: 3, - changeRequest: { state: "open" }, - }), - ).toBe(true); - }); - - it("keeps an explicitly un-settled merged-PR thread active", () => { - const shell = makeShell({ - settledOverride: "active", - activityAt: "2026-04-09T23:59:59.999Z", - }); - expect( - effectiveSettled(shell, { - now: NOW, - autoSettleAfterDays: null, - changeRequest: { state: "merged" }, - }), - ).toBe(false); - }); - - it("never settles a starting session, even with a settled override", () => { - const shell = makeShell({ - settledOverride: "settled", - activityAt: STALE, - sessionStatus: "starting", - }); - expect( - effectiveSettled(shell, { - now: NOW, - autoSettleAfterDays: 3, - changeRequest: { state: "merged" }, - }), - ).toBe(false); - }); - - it("keeps a new turn active from queued through starting and running", () => { - const requestedAt = "2026-04-09T12:00:00.000Z"; - const transitionNow = "2026-04-09T12:00:30.000Z"; - const base = makeShell({ - settledOverride: null, - activityAt: STALE, - }); - const queued: OrchestrationThreadShell = { - ...base, - latestUserMessageAt: requestedAt, - latestTurn: null, - session: null, - }; - const starting: OrchestrationThreadShell = { - ...queued, - session: { - threadId: queued.id, - status: "starting", - providerName: "Codex", - runtimeMode: "full-access", - activeTurnId: null, - lastError: null, - updatedAt: requestedAt, - }, - }; - const running: OrchestrationThreadShell = { - ...starting, - session: { - ...starting.session!, - status: "running", - activeTurnId: TurnId.make("turn-new"), - }, - }; - - for (const shell of [queued, starting, running]) { - expect( - effectiveSettled(shell, { - now: transitionNow, - autoSettleAfterDays: 3, - changeRequest: { state: "merged" }, - }), - ).toBe(false); - } - }); - - it("uses a strict inactivity boundary and honors a null threshold", () => { - const boundary = makeShell({ - activityAt: "2026-04-07T00:00:00.000Z", - }); - const stale = makeShell({ activityAt: STALE }); - - expect(effectiveSettled(boundary, { now: NOW, autoSettleAfterDays: 3 })).toBe(false); - expect(effectiveSettled(stale, { now: NOW, autoSettleAfterDays: null })).toBe(false); - }); -}); - -describe("hasQueuedTurnStart", () => { - const QUEUED_AT = "2026-04-09T12:00:00.000Z"; - // Within the adoption grace window of the queued message. - const JUST_AFTER = { now: "2026-04-09T12:00:30.000Z" }; - - it("flags a user message no turn has picked up, within the grace window", () => { - const noTurn = { latestUserMessageAt: QUEUED_AT, latestTurn: null, session: null }; - expect(hasQueuedTurnStart(noTurn, JUST_AFTER)).toBe(true); - - const staleTurn = { - ...makeShell({ activityAt: FRESH }), - latestUserMessageAt: QUEUED_AT, - }; - expect(hasQueuedTurnStart(staleTurn, JUST_AFTER)).toBe(true); - }); - - it("expires after the grace window: an unadopted message is a failed start, not queued work", () => { - const noTurn = { latestUserMessageAt: QUEUED_AT, latestTurn: null, session: null }; - expect(hasQueuedTurnStart(noTurn, { now: "2026-04-09T12:03:00.000Z" })).toBe(false); - // Historical shells (e.g. from servers that never carried latestTurn) - // must never read as queued. - expect(hasQueuedTurnStart(noTurn, { now: NOW })).toBe(false); - }); - - it("clears once a turn adopts the message or the start fails", () => { - const adopted = { - ...makeShell({ activityAt: QUEUED_AT }), - latestUserMessageAt: QUEUED_AT, - }; - expect(hasQueuedTurnStart(adopted, JUST_AFTER)).toBe(false); - - const failed = makeShell({ activityAt: FRESH }); - const failedShell = { - ...failed, - latestUserMessageAt: QUEUED_AT, - session: { - threadId: failed.id, - status: "error" as const, - providerName: "Codex", - runtimeMode: "full-access" as const, - activeTurnId: null, - lastError: "boom", - updatedAt: NOW, - }, - }; - expect(hasQueuedTurnStart(failedShell, JUST_AFTER)).toBe(false); - }); - - it("is quiet without user messages", () => { - expect(hasQueuedTurnStart(makeShell({ activityAt: FRESH }), JUST_AFTER)).toBe(false); - }); - - it("bounds the grace window in both directions: a future-stamped message is skew, not queued work", () => { - // Message timestamps originate on other devices; a clock an hour ahead - // must not hold the queued state for the whole skew. - const skewed = { - latestUserMessageAt: "2026-04-09T13:00:00.000Z", - latestTurn: null, - session: null, - }; - expect(hasQueuedTurnStart(skewed, { now: "2026-04-09T12:00:00.000Z" })).toBe(false); - // A small negative age (within the grace window) still reads as queued. - const slightlyAhead = { - latestUserMessageAt: "2026-04-09T12:00:30.000Z", - latestTurn: null, - session: null, - }; - expect(hasQueuedTurnStart(slightlyAhead, { now: "2026-04-09T12:00:00.000Z" })).toBe(true); - }); -}); - -describe("canSettle", () => { - it("blocks every state effectiveSettled refuses to classify as settled", () => { - expect(canSettle(makeShell({ activityAt: FRESH }), { now: NOW })).toBe(true); - expect( - canSettle(makeShell({ activityAt: FRESH, sessionStatus: "starting" }), { now: NOW }), - ).toBe(false); - expect( - canSettle(makeShell({ activityAt: FRESH, sessionStatus: "running" }), { now: NOW }), - ).toBe(false); - expect(canSettle(makeShell({ activityAt: FRESH, pending: "approval" }), { now: NOW })).toBe( - false, - ); - expect(canSettle(makeShell({ activityAt: FRESH, pending: "user-input" }), { now: NOW })).toBe( - false, - ); - }); - - it("blocks settling a queued turn start, only within the grace window", () => { - const queued = { - ...makeShell({ activityAt: FRESH }), - latestUserMessageAt: "2026-04-09T12:00:00.000Z", - }; - const justAfter = "2026-04-09T12:00:30.000Z"; - expect(canSettle(queued, { now: justAfter })).toBe(false); - // effectiveSettled must agree: queued work never auto-settles either, - // even with a merged PR. - expect( - effectiveSettled(queued, { - now: justAfter, - autoSettleAfterDays: 3, - changeRequest: { state: "merged" }, - }), - ).toBe(false); - // Past the window the message is a failed/stale start: settleable again. - expect(canSettle(queued, { now: NOW })).toBe(true); - }); - - it("lets a server-accepted settle overrule the clock-derived queued blocker", () => { - // The settle action ran with wall-clock `now` (past the grace window); - // the list partition re-evaluates with a minute-floored `now` that is - // still INSIDE the window. settledAt >= message time proves the server - // already adjudicated this exact message, so the row must not snap back - // to active until the coarser clock catches up. - const messageAt = "2026-04-09T12:00:00.000Z"; - const flooredNow = "2026-04-09T12:01:00.000Z"; - const base = makeShell({ settledOverride: "settled", activityAt: null }); - const settledAfterMessage = { - ...base, - latestUserMessageAt: messageAt, - settledAt: "2026-04-09T12:02:10.000Z", - }; - expect(hasQueuedTurnStart(settledAfterMessage, { now: flooredNow })).toBe(true); - expect(effectiveSettled(settledAfterMessage, { now: flooredNow, autoSettleAfterDays: 3 })).toBe( - true, - ); - - // A message NEWER than settledAt is genuinely new work: still blocked - // until the server's auto-unsettle lands. - const messageAfterSettle = { - ...base, - latestUserMessageAt: "2026-04-09T12:03:00.000Z", - settledAt: "2026-04-09T12:02:10.000Z", - }; - expect( - effectiveSettled(messageAfterSettle, { - now: "2026-04-09T12:03:30.000Z", - autoSettleAfterDays: 3, - }), - ).toBe(false); - }); - - it("agrees with effectiveSettled's blockers for explicitly settled shells", () => { - // Anything canSettle rejects must render as active even when the user - // settled it earlier. - const blocked = makeShell({ - settledOverride: "settled", - activityAt: FRESH, - pending: "user-input", - }); - expect(canSettle(blocked, { now: NOW })).toBe(false); - expect(effectiveSettled(blocked, { now: NOW, autoSettleAfterDays: 3 })).toBe(false); - }); -}); diff --git a/packages/client-runtime/src/state/threadSettled.ts b/packages/client-runtime/src/state/threadSettled.ts index 8ccf0d230efd..f5209a09e499 100644 --- a/packages/client-runtime/src/state/threadSettled.ts +++ b/packages/client-runtime/src/state/threadSettled.ts @@ -1,100 +1,6 @@ // @effect-diagnostics globalDate:off -- UI snooze presets use local calendar boundaries and Intl labels. import type { OrchestrationThreadShell } from "@t3tools/contracts"; -export type ChangeRequestStateLike = "open" | "closed" | "merged"; - -/** - * The slice of a change request the settle rules need. `updatedAt` is the - * provider's last-activity timestamp; for a merged/closed request it bounds - * when the terminal state landed. - */ -export interface ChangeRequestSettleSource { - readonly state: ChangeRequestStateLike; - readonly updatedAt?: string | null | undefined; -} - -/** What the settle rules need to know about the thread's own timeline. */ -export type ThreadActivitySource = Pick< - OrchestrationThreadShell, - "createdAt" | "latestUserMessageAt" | "latestTurn" ->; - -/** - * Latest USER-initiated activity: messages and the turn requests they start, - * deliberately not the agent-side started/completed stamps. The settle-on- - * merge anchor uses this so a merge landing mid-turn still settles the - * thread when that turn finishes, while a user re-engaging after the merge - * blocks it for good. Falls back to creation time for untouched threads. - */ -function threadUserActivityAnchorAt(thread: ThreadActivitySource): string { - const messageAt = thread.latestUserMessageAt; - const requestedAt = thread.latestTurn?.requestedAt; - let anchor = thread.createdAt; - for (const candidate of [messageAt, requestedAt]) { - if (candidate != null && Date.parse(candidate) > Date.parse(anchor)) { - anchor = candidate; - } - } - return anchor; -} - -/** - * Returns whether the change request settles the thread immediately. A - * terminal request settles the thread only while it postdates every user- - * initiated event in it: settling on a merge happens ONCE. A request last - * touched before the thread was created is inherited branch history (a new - * thread started at a worktree root whose PR already merged), and one older - * than the user's latest engagement was already adjudicated — re-engaging a - * thread whose PR merged is the user saying the conversation outlived the - * PR. Unknown timestamps keep the old always-settle behavior. - */ -export function changeRequestAutoSettles( - changeRequest: ChangeRequestSettleSource | null | undefined, - options: { - readonly autoSettleOnMerge?: boolean | undefined; - readonly thread?: ThreadActivitySource | null | undefined; - } = {}, -): boolean { - if (changeRequest == null) return false; - const terminal = - changeRequest.state === "closed" || - (changeRequest.state === "merged" && options.autoSettleOnMerge !== false); - if (!terminal) return false; - if (changeRequest.updatedAt == null || options.thread == null) return true; - const updatedAtMs = Date.parse(changeRequest.updatedAt); - const anchorAtMs = Date.parse(threadUserActivityAnchorAt(options.thread)); - // Malformed timestamps fall back to settling, matching servers that never - // report updatedAt. - if (Number.isNaN(updatedAtMs) || Number.isNaN(anchorAtMs)) return true; - return updatedAtMs >= anchorAtMs; -} - -const DAY_MS = 24 * 60 * 60 * 1_000; - -export function threadLastActivityAt( - shell: Pick, -): string | null { - const candidates = [ - shell.latestUserMessageAt, - shell.latestTurn?.requestedAt, - shell.latestTurn?.startedAt, - shell.latestTurn?.completedAt, - ]; - let latest: string | null = null; - let latestTimestamp = Number.NEGATIVE_INFINITY; - - for (const candidate of candidates) { - if (candidate === null || candidate === undefined) continue; - const timestamp = Date.parse(candidate); - if (timestamp > latestTimestamp) { - latest = candidate; - latestTimestamp = timestamp; - } - } - - return latest; -} - /** * A queued turn start lives for at most this long: session adoption takes * seconds, so a user message still unadopted after the grace window is a @@ -103,6 +9,7 @@ export function threadLastActivityAt( * such threads would be permanently unsettleable. */ export const QUEUED_TURN_START_GRACE_MS = 2 * 60 * 1_000; +const DAY_MS = 24 * 60 * 60 * 1_000; /** * A user message no turn has picked up yet: the turn.start command was @@ -137,28 +44,6 @@ export function hasQueuedTurnStart( ); } -/** - * A thread may be settled only when none of effectiveSettled's activity - * blockers hold. This is deliberately the same list: anything the partition - * refuses to CLASSIFY as settled must also be refused as a settle TARGET. - * The server enforces its own invariants; this client-side twin exists so - * the UI can disable/reject before a round trip. - */ -export function canSettle( - shell: Pick< - OrchestrationThreadShell, - "hasPendingApprovals" | "hasPendingUserInput" | "session" | "latestUserMessageAt" | "latestTurn" - >, - options: { readonly now: string }, -): boolean { - if (shell.hasPendingApprovals || shell.hasPendingUserInput) return false; - if (shell.session?.status === "starting" || shell.session?.status === "running") return false; - // Queued work is as blocked-on-progress as a live session: settling it - // (or auto-settling it on a closed PR) would hide a just-requested turn. - if (hasQueuedTurnStart(shell, options)) return false; - return true; -} - /** * The snooze lifecycle fields plus everything needed to detect a raised * hand. Snooze is an overlay on the active state: a snoozed thread stays @@ -181,8 +66,7 @@ export type ThreadSnoozeShell = Pick< * the session failed, or a run completed after the snooze was set — the * v1 taste of event-based snooze ("something happened" wakes early). * Raising a hand never clears the server-side snooze fields; it only stops - * the thread from CLASSIFYING as snoozed, exactly like blocked work and - * effectiveSettled. + * the thread from classifying as snoozed. */ export function threadRaisedHandWhileSnoozed(shell: ThreadSnoozeShell): boolean { if (shell.hasPendingApprovals || shell.hasPendingUserInput) return true; @@ -283,79 +167,6 @@ export function threadWokeAt( return wakeAtMs <= Date.parse(options.now) ? shell.snoozedUntil : null; } -/** - * Settled resolution over the server-backed settled lifecycle. Activity - * blockers (pending approval/user-input, a live session, an unadjudicated - * queued turn) are checked first and hold a thread active regardless of any - * override. Past the blockers, the explicit user override (thread.settle / - * thread.unsettle commands, projected into settledOverride + settledAt) - * wins in both directions; without one, a thread can auto-settle on a - * merged PR or always on a closed PR (both only while the terminal state is - * the thread's latest event, see changeRequestAutoSettles), or settles on - * inactivity past the window. - * An open PR blocks the inactivity path entirely. The server - * un-settles on real activity (user message, session start, approval/ - * user-input request), so an override never goes stale silently. - */ -export function effectiveSettled( - shell: OrchestrationThreadShell, - options: { - readonly now: string; - readonly autoSettleAfterDays: number | null; - readonly autoSettleOnMerge?: boolean; - readonly changeRequest?: ChangeRequestSettleSource | null; - }, -): boolean { - // Blocked work must remain visible even when a user explicitly settled it. - if (shell.hasPendingApprovals || shell.hasPendingUserInput) return false; - if (shell.session?.status === "starting" || shell.session?.status === "running") return false; - if (hasQueuedTurnStart(shell, { now: options.now })) { - // The queued-turn blocker alone is forgivable: it is clock-derived, and - // list callers pass a coarser `now` than the settle action used. When - // the server already adjudicated the queued message by accepting a - // settle after it (settledAt stamps server accept time), trust that - // ruling — otherwise a settle near the grace boundary leaves the row - // pinned active until the caller's clock ticks over. A message NEWER - // than settledAt is genuinely new work and keeps the block until the - // server's auto-unsettle lands. - const serverAdjudicated = - shell.settledOverride === "settled" && - shell.settledAt !== null && - shell.latestUserMessageAt !== null && - Date.parse(shell.settledAt) >= Date.parse(shell.latestUserMessageAt); - if (!serverAdjudicated) return false; - } - if (shell.settledOverride === "settled") return true; - // "active" is the explicit keep-active pin: it suppresses auto-settle - // until real activity clears it server-side. - if (shell.settledOverride === "active") return false; - if ( - changeRequestAutoSettles(options.changeRequest, { - autoSettleOnMerge: options.autoSettleOnMerge, - thread: shell, - }) - ) { - return true; - } - // An open PR is unfinished business regardless of how long the thread has - // been quiet: review can take days, and hiding the thread would bury the - // work waiting on it. A configured merge, a close, or an explicit user - // settle resolves it. - if (options.changeRequest?.state === "open") return false; - if (options.autoSettleAfterDays === null) return false; - - const lastActivityAt = threadLastActivityAt(shell); - if (lastActivityAt === null) return false; - - // threadLastActivityAt only returns candidates whose Date.parse beat - // -Infinity, so this parse is a real number; a malformed `now` yields NaN, - // the comparison is false, and the thread stays active (never a surprise - // auto-settle on bad input). - return ( - Date.parse(lastActivityAt) < Date.parse(options.now) - options.autoSettleAfterDays * DAY_MS - ); -} - const HOUR_MS = 60 * 60 * 1_000; const EVENING_HOUR = 18; const MORNING_HOUR = 9; @@ -394,7 +205,9 @@ function addSnoozeDays(base: Date, days: number): Date { /** * Shared "snooze until" choices for every client. "This evening" only * appears while it is meaningfully before evening; after that the calendar - * choices start at "Tomorrow". + * choices start at "Tomorrow". Calendar presets that land on the same + * instant collapse: on Sundays "Tomorrow" and "Next week" are both Monday + * morning, so only "Tomorrow" is offered. */ export function resolveSnoozePresets(now: Date): ReadonlyArray { const inAnHour = new Date(now.getTime() + HOUR_MS); @@ -434,12 +247,14 @@ export function resolveSnoozePresets(now: Date): ReadonlyArray { const daysUntilMonday = (1 - now.getDay() + 7) % 7 || 7; const nextWeek = snoozeAtHour(addSnoozeDays(now, daysUntilMonday), MORNING_HOUR); - presets.push({ - id: "next-week", - label: "Next week", - whenLabel: `${nextWeek.toLocaleDateString(undefined, { weekday: "short" })} ${snoozeTimeOfDayLabel(nextWeek)}`, - snoozedUntil: nextWeek.toISOString(), - }); + if (nextWeek.getTime() !== tomorrow.getTime()) { + presets.push({ + id: "next-week", + label: "Next week", + whenLabel: `${nextWeek.toLocaleDateString(undefined, { weekday: "short" })} ${snoozeTimeOfDayLabel(nextWeek)}`, + snoozedUntil: nextWeek.toISOString(), + }); + } return presets; } diff --git a/packages/client-runtime/src/state/threadSnoozed.test.ts b/packages/client-runtime/src/state/threadSnoozed.test.ts index ff0c7d5d8e56..8a62103950bf 100644 --- a/packages/client-runtime/src/state/threadSnoozed.test.ts +++ b/packages/client-runtime/src/state/threadSnoozed.test.ts @@ -6,12 +6,14 @@ import { describe, expect, it } from "vite-plus/test"; import { canSnooze, effectiveSnoozed, + hasQueuedTurnStart, resolveSnoozePresets, snoozeWakeLabel, threadRaisedHandWhileSnoozed, threadWokeAt, type ThreadSnoozeShell, } from "./threadSettled.ts"; +import type { OrchestrationThreadShell } from "@t3tools/contracts"; const NOW = "2026-04-10T12:00:00.000Z"; const SNOOZED_AT = "2026-04-10T09:00:00.000Z"; @@ -61,6 +63,15 @@ function makeShell(input: { }; } +type QueuedTurnShell = Pick< + OrchestrationThreadShell, + "latestUserMessageAt" | "latestTurn" | "session" +>; + +function makeQueuedTurnShell(overrides: Partial = {}): QueuedTurnShell { + return { latestUserMessageAt: null, latestTurn: null, session: null, ...overrides }; +} + describe("effectiveSnoozed", () => { it("hides a thread whose wake time is in the future", () => { expect(effectiveSnoozed(makeShell({ snoozedUntil: FUTURE_WAKE }), { now: NOW })).toBe(true); @@ -202,6 +213,55 @@ describe("canSnooze", () => { }); }); +describe("hasQueuedTurnStart", () => { + it("expires queued state after two minutes", () => { + const thread = makeQueuedTurnShell({ + latestUserMessageAt: "2026-04-10T11:57:59.000Z", + }); + expect(hasQueuedTurnStart(thread, { now: NOW })).toBe(false); + }); + + it("clears queued state when a turn adopts the message or the session fails", () => { + const messageAt = "2026-04-10T11:59:00.000Z"; + const adopted = makeQueuedTurnShell({ + latestUserMessageAt: messageAt, + latestTurn: { + turnId: TurnId.make("turn-adopted"), + state: "running", + requestedAt: messageAt, + startedAt: null, + completedAt: null, + assistantMessageId: null, + }, + }); + const failed = makeQueuedTurnShell({ + latestUserMessageAt: messageAt, + session: { + threadId: ThreadId.make("thread-failed"), + status: "error", + providerName: "Codex", + runtimeMode: "full-access", + activeTurnId: null, + lastError: "failed", + updatedAt: NOW, + }, + }); + expect(hasQueuedTurnStart(adopted, { now: NOW })).toBe(false); + expect(hasQueuedTurnStart(failed, { now: NOW })).toBe(false); + }); + + it("bounds future client clock skew", () => { + const farAhead = makeQueuedTurnShell({ + latestUserMessageAt: "2026-04-10T12:03:00.000Z", + }); + const slightlyAhead = makeQueuedTurnShell({ + latestUserMessageAt: "2026-04-10T12:01:00.000Z", + }); + expect(hasQueuedTurnStart(farAhead, { now: NOW })).toBe(false); + expect(hasQueuedTurnStart(slightlyAhead, { now: NOW })).toBe(true); + }); +}); + describe("threadWokeAt", () => { it("is null for never-snoozed and still-snoozed threads", () => { expect(threadWokeAt(makeShell({}), { now: NOW })).toBe(null); @@ -297,4 +357,17 @@ describe("resolveSnoozePresets", () => { expect(nextWeek.getDay()).toBe(1); expect(nextWeek.getDate()).toBe(13); }); + + it("drops next week on Sundays, when it lands on the same Monday as tomorrow", () => { + // Sunday 2026-08-30 07:01: "Tomorrow" and "Next week" are both Monday 9:00. + const presets = resolveSnoozePresets(localDate(2026, 8, 30, 7, 1)); + expect(presets.map((preset) => preset.id)).toEqual([ + "hour", + "three-hours", + "evening", + "tomorrow", + ]); + const tomorrow = new Date(presets.find((preset) => preset.id === "tomorrow")!.snoozedUntil); + expect(tomorrow.getDay()).toBe(1); + }); }); diff --git a/packages/client-runtime/src/state/threadSort.ts b/packages/client-runtime/src/state/threadSort.ts index 9352d58dbc82..aaac254031f3 100644 --- a/packages/client-runtime/src/state/threadSort.ts +++ b/packages/client-runtime/src/state/threadSort.ts @@ -69,6 +69,24 @@ export function getThreadSortTimestamp( return getLatestUserMessageTimestamp(thread); } +/** + * Sort anchor for the active thread list: creation time, re-anchored to + * unsettledAt when the thread last re-entered the active list (an explicit + * un-settle, or a settled thread waking on activity). The list stays static + * between lifecycle transitions, but an un-settled thread surfaces at the + * top instead of sinking back to its creation-order slot. Shared by web and + * mobile so both render the same order. Malformed timestamps sink to 0. + */ +export function activeThreadAnchorTimestampMs(thread: { + readonly createdAt: string; + readonly unsettledAt?: string | null | undefined; +}): number { + return Math.max( + toSortableTimestamp(thread.createdAt) ?? 0, + toSortableTimestamp(thread.unsettledAt ?? undefined) ?? 0, + ); +} + export function sortThreads( threads: readonly T[], sortOrder: SidebarThreadSortOrder, diff --git a/packages/client-runtime/src/state/threads-pagination.test.ts b/packages/client-runtime/src/state/threads-pagination.test.ts index 62cad18f89e0..2cede4f5b3e2 100644 --- a/packages/client-runtime/src/state/threads-pagination.test.ts +++ b/packages/client-runtime/src/state/threads-pagination.test.ts @@ -156,6 +156,7 @@ const makeHarness = Effect.fn("TestThreadPagination.makeHarness")(function* (opt initialConfig: Effect.succeed({ threadSnapshotPagination: options?.paginationCapability !== false, } as never), + subscribeServerConfig: (input) => client.subscribeServerConfig(input), ready: Effect.void, probe: Effect.void, closed: Effect.never, diff --git a/packages/client-runtime/src/state/threads-sync.test.ts b/packages/client-runtime/src/state/threads-sync.test.ts index c2df434e8e77..d94ed3a3fd74 100644 --- a/packages/client-runtime/src/state/threads-sync.test.ts +++ b/packages/client-runtime/src/state/threads-sync.test.ts @@ -112,6 +112,7 @@ function testSession( ? ({ threadResumeCompletionMarker: true } as never) : ({} as never), ), + subscribeServerConfig: (input) => client.subscribeServerConfig(input), ready: Effect.void, probe: Effect.void, closed: Effect.never, diff --git a/packages/client-runtime/src/state/vcs.test.ts b/packages/client-runtime/src/state/vcs.test.ts index 0a6264c62078..d7a4692fc317 100644 --- a/packages/client-runtime/src/state/vcs.test.ts +++ b/packages/client-runtime/src/state/vcs.test.ts @@ -86,6 +86,7 @@ function session(client: WsRpcProtocolClient): RpcSession { return { client, initialConfig: Effect.never, + subscribeServerConfig: (input) => client.subscribeServerConfig(input), ready: Effect.void, probe: Effect.void, closed: Effect.never, diff --git a/packages/client-runtime/src/state/vcsAction.test.ts b/packages/client-runtime/src/state/vcsAction.test.ts index b936246dc823..905972975606 100644 --- a/packages/client-runtime/src/state/vcsAction.test.ts +++ b/packages/client-runtime/src/state/vcsAction.test.ts @@ -84,6 +84,7 @@ function session(client: WsRpcProtocolClient): RpcSession { return { client, initialConfig: Effect.never, + subscribeServerConfig: (input) => client.subscribeServerConfig(input), ready: Effect.void, probe: Effect.void, closed: Effect.never, diff --git a/packages/client-runtime/src/voice-input/controller.test.ts b/packages/client-runtime/src/voice-input/controller.test.ts new file mode 100644 index 000000000000..5f26b882b694 --- /dev/null +++ b/packages/client-runtime/src/voice-input/controller.test.ts @@ -0,0 +1,535 @@ +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { + resetVoiceInputGlobalsForTests, + resolveTranscriptCommit, + VoiceInputController, + VOICE_RECORDING_LIMIT_SECONDS, + voiceInputBlocksSubmission, + type VoiceDraftSnapshot, + type VoiceInputControllerDependencies, + type VoiceRecorder, +} from "./controller.ts"; +import type { PreparedVoiceTranscription, VoiceTranscriber } from "./transcription.ts"; + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + +class TestRecorder implements VoiceRecorder { + uri: string | null = "file:///voice.m4a"; + readonly prepareToRecordAsync = vi.fn(async () => undefined); + readonly record = vi.fn(); + readonly stop = vi.fn(async () => undefined); +} + +function preparedTranscription( + transcribe: PreparedVoiceTranscription["transcribe"] = async () => "new text", +): PreparedVoiceTranscription { + return { locale: "en-US", transcribe }; +} + +function draft(overrides: Partial = {}): VoiceDraftSnapshot { + return { + ownerKey: "environment:thread", + text: "hello world", + selection: { start: 6, end: 11 }, + revision: 1, + ...overrides, + }; +} + +function createHarness( + overrides: Partial = {}, + initialDraft = draft(), +) { + const recorder = new TestRecorder(); + let currentDraft: VoiceDraftSnapshot | null = initialDraft; + const commits: Array<{ text: string; selection: { start: number; end: number } }> = []; + const deleted: string[] = []; + const dependencies: VoiceInputControllerDependencies = { + recorder, + getTranscriber: () => ({ prepare: async () => preparedTranscription() }), + requestPermission: async () => ({ granted: true, canAskAgain: true }), + configureRecording: async () => undefined, + releaseRecording: async () => undefined, + deleteRecording: (uri) => deleted.push(uri), + readDraft: () => currentDraft, + commitDraft: (text, selection) => commits.push({ text, selection }), + onStateChange: vi.fn(), + ...overrides, + }; + return { + controller: new VoiceInputController(dependencies), + recorder, + commits, + deleted, + setDraft: (next: VoiceDraftSnapshot | null) => { + currentDraft = next; + }, + }; +} + +describe("resolveTranscriptCommit", () => { + it("replaces the recorded UTF-16 selection around emoji and composer tokens", () => { + const text = "Fix 🧪 then $review please"; + const tokenStart = text.indexOf("$review"); + const captured = draft({ + text, + selection: { start: tokenStart, end: tokenStart + "$review".length }, + }); + + expect(resolveTranscriptCommit(captured, captured, "use the mobile skill", "en-US")).toEqual({ + kind: "commit", + text: "Fix 🧪 then use the mobile skill please", + selection: { start: tokenStart + "use the mobile skill".length, end: tokenStart + 20 }, + }); + }); + + it("does not replace text after the owner, text, or revision changes", () => { + const captured = draft(); + expect( + resolveTranscriptCommit(captured, draft({ ownerKey: "other" }), "text", "en-US"), + ).toEqual({ + kind: "stale", + }); + expect(resolveTranscriptCommit(captured, draft({ text: "newer" }), "text", "en-US")).toEqual({ + kind: "stale", + }); + expect(resolveTranscriptCommit(captured, draft({ revision: 2 }), "text", "en-US")).toEqual({ + kind: "stale", + }); + }); + + it("adds English spacing at empty start, middle, and end caret boundaries", () => { + const atEnd = draft({ + text: "Fix cache.", + selection: { start: "Fix cache.".length, end: "Fix cache.".length }, + }); + expect(resolveTranscriptCommit(atEnd, atEnd, "Also fix tests.", "en-US")).toMatchObject({ + kind: "commit", + text: "Fix cache. Also fix tests.", + }); + expect(resolveTranscriptCommit(atEnd, atEnd, "Also fix tests.", "en_US")).toMatchObject({ + kind: "commit", + text: "Fix cache. Also fix tests.", + }); + + const atStart = draft({ text: "Fix cache.", selection: { start: 0, end: 0 } }); + expect(resolveTranscriptCommit(atStart, atStart, "First", "en-US")).toMatchObject({ + kind: "commit", + text: "First Fix cache.", + }); + + const inMiddle = draft({ text: "Fix cache.", selection: { start: 4, end: 4 } }); + expect(resolveTranscriptCommit(inMiddle, inMiddle, "also", "en-US")).toMatchObject({ + kind: "commit", + text: "Fix also cache.", + }); + }); + + it("does not add English boundary spaces to CJK or selected inline text", () => { + const cjk = draft({ text: "修正キャッシュ", selection: { start: 8, end: 8 } }); + expect(resolveTranscriptCommit(cjk, cjk, "テストも", "ja-JP")).toMatchObject({ + kind: "commit", + text: "修正キャッシュテストも", + }); + + const selected = draft({ text: "one $skill two", selection: { start: 4, end: 10 } }); + expect(resolveTranscriptCommit(selected, selected, "new", "en-US")).toMatchObject({ + kind: "commit", + text: "one new two", + }); + }); +}); + +describe("VoiceInputController", () => { + beforeEach(() => resetVoiceInputGlobalsForTests()); + + it("checks support and permission before recording", async () => { + const unsupported = createHarness({ getTranscriber: () => null }); + await unsupported.controller.start(); + expect(unsupported.controller.currentState.error).toContain("not available"); + expect(unsupported.recorder.record).not.toHaveBeenCalled(); + + const denied = createHarness({ + requestPermission: async () => ({ granted: false, canAskAgain: false }), + }); + await denied.controller.start(); + expect(denied.controller.currentState.errorAction).toBe("settings"); + expect(denied.recorder.record).not.toHaveBeenCalled(); + }); + + it.each(["permission", "transcription"] as const)( + "clears %s errors when switching to another draft", + async (failure) => { + const harness = createHarness( + failure === "permission" + ? { requestPermission: async () => ({ granted: false, canAskAgain: false }) } + : { + getTranscriber: () => ({ + prepare: async () => + preparedTranscription(async () => { + throw new Error("Transcription failed"); + }), + }), + }, + ); + await harness.controller.start(); + await harness.controller.stop(); + expect(harness.controller.currentState).toMatchObject({ + phase: "error", + error: expect.any(String), + errorAction: failure === "permission" ? "settings" : "retry", + }); + + harness.setDraft(draft({ ownerKey: "environment:other-thread" })); + harness.controller.ownerChanged(); + + expect(harness.controller.currentState).toEqual({ + phase: "idle", + error: null, + errorAction: null, + }); + expect(harness.commits).toEqual([]); + }, + ); + + it.each(["permission", "preparation", "recording"] as const)( + "keeps the selected transcriber when preferences change during %s", + async (changeDuring) => { + const permission = deferred<{ granted: boolean; canAskAgain: boolean }>(); + const permissionEntered = deferred(); + const preparation = deferred(); + const preparationEntered = deferred(); + const preparationSignals: AbortSignal[] = []; + const transcriptionSignals: AbortSignal[] = []; + const transcriber = (text: string): VoiceTranscriber => ({ + prepare: async ({ signal }) => { + preparationSignals.push(signal); + preparationEntered.resolve(undefined); + await preparation.promise; + return preparedTranscription(async (_uri, { signal }) => { + transcriptionSignals.push(signal); + return text; + }); + }, + }); + const first = transcriber("first choice"); + const second = transcriber("second choice"); + let selected = first; + const harness = createHarness({ + getTranscriber: () => selected, + requestPermission: () => { + permissionEntered.resolve(undefined); + return permission.promise; + }, + }); + + const starting = harness.controller.start(); + await permissionEntered.promise; + if (changeDuring === "permission") selected = second; + permission.resolve({ granted: true, canAskAgain: true }); + await preparationEntered.promise; + if (changeDuring === "preparation") selected = second; + preparation.resolve(undefined); + await starting; + if (changeDuring === "recording") selected = second; + await harness.controller.stop(); + + expect(harness.commits.map((commit) => commit.text)).toEqual(["hello first choice"]); + + await harness.controller.start(); + await harness.controller.stop(); + + expect(harness.commits.map((commit) => commit.text)).toEqual([ + "hello first choice", + "hello second choice", + ]); + expect(preparationSignals).toHaveLength(2); + expect(transcriptionSignals).toHaveLength(2); + expect(transcriptionSignals[0]).toBe(preparationSignals[0]); + expect(transcriptionSignals[1]).toBe(preparationSignals[1]); + expect(preparationSignals[1]).not.toBe(preparationSignals[0]); + }, + ); + + it("blocks submit while voice input can still change the draft", () => { + expect(voiceInputBlocksSubmission({ phase: "preparing", error: null, errorAction: null })).toBe( + true, + ); + expect(voiceInputBlocksSubmission({ phase: "recording", error: null, errorAction: null })).toBe( + true, + ); + expect( + voiceInputBlocksSubmission({ phase: "transcribing", error: null, errorAction: null }), + ).toBe(true); + expect(voiceInputBlocksSubmission({ phase: "idle", error: null, errorAction: null })).toBe( + false, + ); + }); + + it("uses the native five-minute cap and commits one final transcript", async () => { + const harness = createHarness(); + await harness.controller.start(); + expect(harness.recorder.record).toHaveBeenCalledWith({ + forDuration: VOICE_RECORDING_LIMIT_SECONDS, + }); + + const stopping = harness.controller.stop(); + harness.controller.handleRecorderStatus({ + isFinished: true, + hasError: false, + error: null, + url: "file:///voice.m4a", + }); + await stopping; + + expect(harness.commits).toEqual([ + { text: "hello new text", selection: { start: 14, end: 14 } }, + ]); + expect(harness.deleted).toEqual(["file:///voice.m4a"]); + }); + + it.each(["cancel", "dispose", "ownerChanged"] as const)( + "holds the session after %s until non-abortable transcription settles", + async (action) => { + const transcription = deferred(); + const transcriptionEntered = deferred(); + const harness = createHarness({ + getTranscriber: () => ({ + prepare: async () => + preparedTranscription((_uri, { signal }) => { + transcriptionEntered.resolve(signal); + return transcription.promise; + }), + }), + }); + await harness.controller.start(); + const stopping = harness.controller.stop(); + const signal = await transcriptionEntered.promise; + expect(signal.aborted).toBe(false); + if (action === "ownerChanged") { + harness.setDraft(draft({ ownerKey: "environment:other-thread" })); + } + harness.controller[action](); + expect(signal.aborted).toBe(true); + + const next = createHarness(); + await next.controller.start(); + expect(next.controller.currentState.error).toContain("already active"); + expect(next.recorder.record).not.toHaveBeenCalled(); + + transcription.resolve("late text"); + await stopping; + + expect(harness.commits).toEqual([]); + expect(harness.deleted).toEqual(["file:///voice.m4a"]); + expect(harness.controller.currentState.phase).toBe("idle"); + + await next.controller.start(); + expect(next.controller.currentState.phase).toBe("recording"); + await next.controller.interruptRecording(); + }, + ); + + it("cancels an in-flight transcriber that rejects when its signal aborts", async () => { + const transcription = deferred(); + const transcriptionEntered = deferred(); + const harness = createHarness({ + getTranscriber: () => ({ + prepare: async () => + preparedTranscription((_uri, { signal }) => { + signal.addEventListener("abort", () => transcription.reject(new Error("aborted")), { + once: true, + }); + transcriptionEntered.resolve(signal); + return transcription.promise; + }), + }), + }); + await harness.controller.start(); + const stopping = harness.controller.stop(); + const signal = await transcriptionEntered.promise; + harness.controller.cancel(); + await stopping; + + expect(signal.aborted).toBe(true); + expect(harness.commits).toEqual([]); + expect(harness.deleted).toEqual(["file:///voice.m4a"]); + expect(harness.controller.currentState.phase).toBe("idle"); + }); + + it("releases the microphone before transcription starts", async () => { + const events: string[] = []; + const harness = createHarness({ + releaseRecording: async () => { + events.push("released"); + }, + getTranscriber: () => ({ + prepare: async () => + preparedTranscription(async () => { + events.push("transcribed"); + return "done"; + }), + }), + }); + await harness.controller.start(); + await harness.controller.stop(); + + expect(events).toEqual(["released", "transcribed"]); + }); + + it("retries audio-session release during final cleanup", async () => { + const releaseRecording = vi + .fn<() => Promise>() + .mockRejectedValueOnce(new Error("busy")) + .mockResolvedValueOnce(undefined); + const harness = createHarness({ releaseRecording }); + await harness.controller.start(); + await harness.controller.stop(); + + expect(releaseRecording).toHaveBeenCalledTimes(2); + }); + + it("leaves transcription with an error when recorder finalization fails", async () => { + const harness = createHarness(); + harness.recorder.stop.mockRejectedValueOnce(new Error("stop failed")); + await harness.controller.start(); + await harness.controller.stop(); + + expect(harness.controller.currentState.phase).toBe("error"); + expect(harness.controller.currentState.error).toContain("finish voice recording"); + }); + + it("ignores a late transcript after the draft owner changes", async () => { + const transcription = deferred(); + const transcriptionEntered = deferred(); + const harness = createHarness({ + getTranscriber: () => ({ + prepare: async () => + preparedTranscription(() => { + transcriptionEntered.resolve(undefined); + return transcription.promise; + }), + }), + }); + await harness.controller.start(); + const stopping = harness.controller.stop(); + await transcriptionEntered.promise; + harness.setDraft(draft({ ownerKey: "environment:other-thread" })); + transcription.resolve("late text"); + await stopping; + + expect(harness.commits).toEqual([]); + expect(harness.controller.currentState.error).toContain("draft changed"); + }); + + it("keeps the app-wide session locked until canceled preparation settles", async () => { + const preparation = deferred(); + const preparationEntered = deferred(); + const first = createHarness({ + getTranscriber: () => ({ + prepare: ({ signal }) => { + preparationEntered.resolve(signal); + return preparation.promise; + }, + }), + }); + const firstStart = first.controller.start(); + const signal = await preparationEntered.promise; + first.controller.cancel(); + expect(signal.aborted).toBe(true); + + const blocked = createHarness(); + await blocked.controller.start(); + expect(blocked.controller.currentState.error).toContain("already active"); + + preparation.resolve(preparedTranscription()); + await firstStart; + expect(first.recorder.record).not.toHaveBeenCalled(); + blocked.controller.cancel(); + + const next = createHarness(); + await next.controller.start(); + expect(next.controller.currentState.phase).toBe("recording"); + await next.controller.interruptRecording(); + }); + + it("does not start the microphone for an owner that changed during preparation", async () => { + const preparation = deferred(); + const preparationEntered = deferred(); + const harness = createHarness({ + getTranscriber: () => ({ + prepare: () => { + preparationEntered.resolve(undefined); + return preparation.promise; + }, + }), + }); + const starting = harness.controller.start(); + await preparationEntered.promise; + harness.setDraft(draft({ ownerKey: "environment:other-thread", text: "other draft" })); + preparation.resolve(preparedTranscription()); + await starting; + + expect(harness.recorder.record).not.toHaveBeenCalled(); + expect(harness.controller.currentState.error).toContain("no longer available"); + }); + + it("discards recorder errors and audio interruptions without transcribing", async () => { + const transcribe = vi.fn(async () => "ignored"); + const preparationEntered = deferred(); + const harness = createHarness({ + getTranscriber: () => ({ + prepare: async ({ signal }) => { + preparationEntered.resolve(signal); + return preparedTranscription(transcribe); + }, + }), + }); + await harness.controller.start(); + const signal = await preparationEntered.promise; + harness.recorder.uri = "file:///reset-empty.m4a"; + await harness.controller.handleRecorderStatus({ + isFinished: true, + hasError: true, + error: "Audio route changed", + url: "file:///voice.m4a", + }); + + expect(harness.commits).toEqual([]); + expect(transcribe).not.toHaveBeenCalled(); + expect(signal.aborted).toBe(true); + expect(harness.controller.currentState.error).toBe("Audio route changed"); + expect(harness.deleted).toEqual(["file:///voice.m4a", "file:///reset-empty.m4a"]); + }); + + it("cancels preparation when the app reaches the background", async () => { + const preparation = deferred(); + const preparationEntered = deferred(); + const harness = createHarness({ + getTranscriber: () => ({ + prepare: ({ signal }) => { + preparationEntered.resolve(signal); + return preparation.promise; + }, + }), + }); + const starting = harness.controller.start(); + const signal = await preparationEntered.promise; + harness.controller.appMovedToBackground(); + expect(signal.aborted).toBe(true); + preparation.resolve(preparedTranscription()); + await starting; + + expect(harness.recorder.record).not.toHaveBeenCalled(); + expect(harness.controller.currentState.error).toContain("background"); + }); +}); diff --git a/packages/client-runtime/src/voice-input/controller.ts b/packages/client-runtime/src/voice-input/controller.ts new file mode 100644 index 000000000000..cb284ad9f85b --- /dev/null +++ b/packages/client-runtime/src/voice-input/controller.ts @@ -0,0 +1,493 @@ +import { replaceTextRange } from "@t3tools/shared/composerTrigger"; + +import type { PreparedVoiceTranscription, VoiceTranscriber } from "./transcription.ts"; + +export const VOICE_RECORDING_LIMIT_SECONDS = 5 * 60; + +export type VoiceInputPhase = "idle" | "preparing" | "recording" | "transcribing" | "error"; + +export type VoiceInputState = { + readonly phase: VoiceInputPhase; + readonly error: string | null; + readonly errorAction: "retry" | "settings" | null; +}; + +export function voiceInputBlocksSubmission(state: VoiceInputState): boolean { + return ( + state.phase === "preparing" || state.phase === "recording" || state.phase === "transcribing" + ); +} + +export function voiceInputFreezesEditor(state: VoiceInputState): boolean { + return voiceInputBlocksSubmission(state); +} + +export type VoiceDraftSnapshot = { + readonly ownerKey: string; + readonly text: string; + readonly selection: { readonly start: number; readonly end: number }; + readonly revision: number; +}; + +export type VoiceRecorderStatus = { + readonly isFinished: boolean; + readonly hasError: boolean; + readonly error: string | null; + readonly url: string | null; +}; + +export interface VoiceRecorder { + readonly uri: string | null; + prepareToRecordAsync(): Promise; + record(options: { readonly forDuration: number }): void; + stop(): Promise; +} + +export type VoiceInputControllerDependencies = { + readonly recorder: VoiceRecorder; + readonly getTranscriber: () => VoiceTranscriber | null; + readonly requestPermission: () => Promise<{ + readonly granted: boolean; + readonly canAskAgain: boolean; + }>; + readonly configureRecording: () => Promise; + readonly releaseRecording: () => Promise; + readonly deleteRecording: (uri: string) => void; + readonly readDraft: () => VoiceDraftSnapshot | null; + readonly commitDraft: ( + text: string, + selection: { readonly start: number; readonly end: number }, + ) => void; + readonly onStateChange: (state: VoiceInputState) => void; +}; + +type TranscriptCommitResult = + | { + readonly kind: "commit"; + readonly text: string; + readonly selection: { readonly start: number; readonly end: number }; + } + | { readonly kind: "stale" } + | { readonly kind: "empty" }; + +export function resolveTranscriptCommit( + captured: VoiceDraftSnapshot, + current: VoiceDraftSnapshot | null, + transcript: string, + locale: string, +): TranscriptCommitResult { + if ( + !current || + current.ownerKey !== captured.ownerKey || + current.text !== captured.text || + current.revision !== captured.revision + ) { + return { kind: "stale" }; + } + + const replacement = transcript.trim(); + if (replacement.length === 0) { + return { kind: "empty" }; + } + + const isEmptySelection = captured.selection.start === captured.selection.end; + const normalizedLocale = locale.replaceAll("_", "-").toLowerCase(); + const usesEnglishSpacing = normalizedLocale === "en" || normalizedLocale.startsWith("en-"); + let insertion = replacement; + if (isEmptySelection && usesEnglishSpacing) { + const left = captured.text[captured.selection.start - 1]; + const right = captured.text[captured.selection.start]; + const leftNeedsBoundary = + left !== undefined && + /[A-Za-z0-9.!?,:;)\]}'"]/.test(left) && + (right === undefined || /\s/.test(right)); + const rightNeedsBoundary = + right !== undefined && + /[A-Za-z0-9([{'"]/.test(right) && + (left === undefined || /\s/.test(left)); + insertion = `${leftNeedsBoundary ? " " : ""}${replacement}${rightNeedsBoundary ? " " : ""}`; + } + + const result = replaceTextRange( + captured.text, + captured.selection.start, + captured.selection.end, + insertion, + ); + return { + kind: "commit", + text: result.text, + selection: { start: result.cursor, end: result.cursor }, + }; +} + +let activeSession: symbol | null = null; +let activeTranscriptionOperation: Promise | null = null; + +function acquireSession(): symbol | null { + if (activeSession) return null; + const token = Symbol("voice-input-session"); + activeSession = token; + return token; +} + +function releaseSession(token: symbol | null): void { + if (token && activeSession === token) activeSession = null; +} + +async function runTranscriptionOperation(operation: () => Promise): Promise { + if (activeTranscriptionOperation) { + throw new Error("voice-operation-busy"); + } + + const promise = operation(); + activeTranscriptionOperation = promise; + try { + return await promise; + } finally { + if (activeTranscriptionOperation === promise) activeTranscriptionOperation = null; + } +} + +function errorCode(error: unknown): string | null { + if (typeof error !== "object" || error === null || !("code" in error)) return null; + return typeof error.code === "string" ? error.code : null; +} + +function preparationErrorMessage(error: unknown): string { + if (error instanceof Error && error.message === "voice-operation-busy") { + return "Voice transcription is still finishing. Try again shortly."; + } + if (errorCode(error) === "unsupported-locale") { + return "Voice transcription is not available for this language."; + } + return "Could not prepare voice transcription."; +} + +function transcriptionErrorMessage(error: unknown): string { + if (error instanceof Error && error.message === "voice-operation-busy") { + return "Voice transcription is still finishing. Try again shortly."; + } + return "Could not transcribe this recording."; +} + +const IDLE_STATE: VoiceInputState = { phase: "idle", error: null, errorAction: null }; + +export class VoiceInputController { + private readonly dependencies: VoiceInputControllerDependencies; + private state: VoiceInputState = IDLE_STATE; + private operationToken = 0; + private sessionToken: symbol | null = null; + private transcription: PreparedVoiceTranscription | null = null; + private transcriptionAbortController: AbortController | null = null; + private capturedDraft: VoiceDraftSnapshot | null = null; + private recordingUri: string | null = null; + private readonly ownedRecordingUris = new Set(); + private recordingConfigured = false; + private finishing = false; + + constructor(dependencies: VoiceInputControllerDependencies) { + this.dependencies = dependencies; + } + + get currentState(): VoiceInputState { + return this.state; + } + + async start(): Promise { + if (this.state.phase !== "idle" && this.state.phase !== "error") return; + const initiatingDraft = this.dependencies.readDraft(); + if (!initiatingDraft) { + this.setError("This draft is no longer available.", "retry"); + return; + } + const sessionToken = acquireSession(); + if (!sessionToken) { + this.setError("Another voice recording is already active.", "retry"); + return; + } + + this.sessionToken = sessionToken; + const operationToken = ++this.operationToken; + const abortController = new AbortController(); + this.transcriptionAbortController = abortController; + this.setState({ phase: "preparing", error: null, errorAction: null }); + + try { + const transcriber = this.dependencies.getTranscriber(); + if (!transcriber) { + this.setError("Voice transcription is not available.", null); + return; + } + + const permission = await this.dependencies.requestPermission(); + if (!this.isCurrent(operationToken)) return; + if (!permission.granted) { + this.setError( + "Microphone access is required for voice input.", + permission.canAskAgain ? "retry" : "settings", + ); + return; + } + + try { + this.transcription = await runTranscriptionOperation(() => + transcriber.prepare({ signal: abortController.signal }), + ); + } catch (error) { + if (this.isCurrent(operationToken)) this.setError(preparationErrorMessage(error), "retry"); + return; + } + if (!this.isCurrent(operationToken)) return; + + await this.dependencies.configureRecording(); + this.recordingConfigured = true; + if (!this.isCurrent(operationToken)) return; + await this.dependencies.recorder.prepareToRecordAsync(); + if (!this.isCurrent(operationToken)) return; + this.recordingUri = this.dependencies.recorder.uri; + this.rememberRecordingUri(this.recordingUri); + + const capturedDraft = this.dependencies.readDraft(); + if (!capturedDraft || capturedDraft.ownerKey !== initiatingDraft.ownerKey) { + this.setError("This draft is no longer available.", "retry"); + return; + } + this.capturedDraft = capturedDraft; + this.dependencies.recorder.record({ forDuration: VOICE_RECORDING_LIMIT_SECONDS }); + this.setState({ phase: "recording", error: null, errorAction: null }); + } catch { + if (this.isCurrent(operationToken)) + this.setError("Could not start voice recording.", "retry"); + } finally { + if (this.isCurrent(operationToken) && this.state.phase === "error") { + await this.releaseResources(); + } else if (!this.isCurrent(operationToken) && !this.finishing) { + await this.releaseResources(); + } + } + } + + stop(): Promise { + if (this.state.phase !== "recording") return Promise.resolve(); + return this.finishRecording(false, null); + } + + cancel(): void { + switch (this.state.phase) { + case "idle": + return; + case "error": + this.setState(IDLE_STATE); + return; + case "preparing": + this.invalidateOperation(); + this.setState(IDLE_STATE); + return; + case "recording": + this.discardRecording(null); + return; + case "transcribing": + this.invalidateOperation(); + this.setState(IDLE_STATE); + return; + } + } + + interruptRecording( + message = "Voice recording was interrupted.", + completedUri: string | null = null, + ): Promise | void { + if (this.state.phase !== "recording") return; + this.rememberRecordingUri(completedUri); + this.recordingUri = completedUri ?? this.recordingUri; + return this.discardRecording(message); + } + + appMovedToBackground(): Promise | void { + if (this.state.phase === "preparing") { + this.invalidateOperation(); + this.setError("Voice input stopped when the app moved to the background.", "retry"); + return; + } + return this.interruptRecording(); + } + + handleRecorderStatus(status: VoiceRecorderStatus): Promise | void { + if (this.state.phase !== "recording") return; + if (status.hasError) { + return this.interruptRecording( + status.error ?? "Voice recording was interrupted.", + status.url, + ); + } + if (status.isFinished) { + if (!status.url) { + return this.interruptRecording(); + } + return this.finishRecording(true, status.url); + } + } + + ownerChanged(): void { + if (this.state.phase === "idle") return; + this.cancel(); + } + + dispose(): void { + if (this.state.phase === "recording") { + this.discardRecording(null); + return; + } + if (this.state.phase === "preparing" || this.state.phase === "transcribing") { + this.invalidateOperation(); + this.setState(IDLE_STATE); + } + } + + private async finishRecording( + alreadyStopped: boolean, + completedUri: string | null, + ): Promise { + if (this.finishing || this.state.phase !== "recording") return; + this.finishing = true; + const operationToken = this.operationToken; + this.setState({ phase: "transcribing", error: null, errorAction: null }); + + try { + if (!alreadyStopped) await this.dependencies.recorder.stop(); + await this.releaseAudioSession(); + this.recordingUri = completedUri ?? this.dependencies.recorder.uri ?? this.recordingUri; + this.rememberRecordingUri(this.recordingUri); + if (!this.isCurrent(operationToken)) return; + if ( + !this.recordingUri || + !this.transcription || + !this.transcriptionAbortController || + !this.capturedDraft + ) { + this.setError("Could not finish voice recording.", "retry"); + return; + } + + const recordingUri = this.recordingUri; + const transcription = this.transcription; + const signal = this.transcriptionAbortController.signal; + const capturedDraft = this.capturedDraft; + let transcript: string; + try { + transcript = await runTranscriptionOperation(() => + transcription.transcribe(recordingUri, { signal }), + ); + } catch (error) { + if (this.isCurrent(operationToken)) { + this.setError(transcriptionErrorMessage(error), "retry"); + } + return; + } + if (!this.isCurrent(operationToken)) return; + + const result = resolveTranscriptCommit( + capturedDraft, + this.dependencies.readDraft(), + transcript, + transcription.locale, + ); + if (result.kind === "stale") { + this.setError( + "The draft changed while voice input was running. The transcript was not added.", + "retry", + ); + return; + } + if (result.kind === "empty") { + this.setError("No speech was detected.", "retry"); + return; + } + + this.dependencies.commitDraft(result.text, result.selection); + this.setState(IDLE_STATE); + } catch { + if (this.isCurrent(operationToken)) { + this.setError("Could not finish voice recording.", "retry"); + } + } finally { + this.finishing = false; + await this.releaseResources(); + } + } + + private async discardRecording(error: string | null): Promise { + this.invalidateOperation(); + this.setState( + error + ? { phase: "error", error, errorAction: "retry" } + : { phase: "idle", error: null, errorAction: null }, + ); + try { + await this.dependencies.recorder.stop(); + this.rememberRecordingUri(this.dependencies.recorder.uri); + } catch { + this.rememberRecordingUri(this.dependencies.recorder.uri); + } finally { + await this.releaseResources(); + } + } + + private async releaseResources(): Promise { + this.rememberRecordingUri(this.recordingUri); + this.rememberRecordingUri(this.dependencies.recorder.uri); + this.recordingUri = null; + for (const uri of this.ownedRecordingUris) { + try { + this.dependencies.deleteRecording(uri); + } catch { + // The cache may already have removed a failed or interrupted recording. + } + } + this.ownedRecordingUris.clear(); + await this.releaseAudioSession(); + releaseSession(this.sessionToken); + this.sessionToken = null; + this.capturedDraft = null; + this.transcription = null; + this.transcriptionAbortController = null; + } + + private rememberRecordingUri(uri: string | null): void { + if (uri) this.ownedRecordingUris.add(uri); + } + + private async releaseAudioSession(): Promise { + if (!this.recordingConfigured) return; + try { + await this.dependencies.releaseRecording(); + this.recordingConfigured = false; + } catch { + // Final cleanup retries if the prompt release before transcription fails. + } + } + + private invalidateOperation(): void { + this.operationToken += 1; + this.transcriptionAbortController?.abort(); + } + + private isCurrent(operationToken: number): boolean { + return operationToken === this.operationToken; + } + + private setError(error: string, errorAction: VoiceInputState["errorAction"]): void { + this.setState({ phase: "error", error, errorAction }); + } + + private setState(state: VoiceInputState): void { + this.state = state; + this.dependencies.onStateChange(state); + } +} + +export function resetVoiceInputGlobalsForTests(): void { + activeSession = null; + activeTranscriptionOperation = null; +} diff --git a/packages/client-runtime/src/voice-input/index.ts b/packages/client-runtime/src/voice-input/index.ts new file mode 100644 index 000000000000..c8c8da455ed1 --- /dev/null +++ b/packages/client-runtime/src/voice-input/index.ts @@ -0,0 +1,21 @@ +export { + VoiceInputController, + VOICE_RECORDING_LIMIT_SECONDS, + resolveTranscriptCommit, + voiceInputBlocksSubmission, + voiceInputFreezesEditor, + type VoiceDraftSnapshot, + type VoiceInputControllerDependencies, + type VoiceInputPhase, + type VoiceInputState, + type VoiceRecorder, + type VoiceRecorderStatus, +} from "./controller.ts"; +export { + VoiceTranscriptionError, + throwIfVoiceTranscriptionAborted, + type PreparedVoiceTranscription, + type VoiceTranscriber, + type VoiceTranscriptionErrorCode, + type VoiceTranscriptionOptions, +} from "./transcription.ts"; diff --git a/packages/client-runtime/src/voice-input/transcription.ts b/packages/client-runtime/src/voice-input/transcription.ts new file mode 100644 index 000000000000..f3ce377acb5f --- /dev/null +++ b/packages/client-runtime/src/voice-input/transcription.ts @@ -0,0 +1,37 @@ +/** Cancellation is cooperative: settle only after the underlying work has stopped. */ +export type VoiceTranscriptionOptions = { + readonly signal: AbortSignal; +}; + +/** Binds a recording to its selected implementation and resolved locale. */ +export type PreparedVoiceTranscription = { + readonly locale: string; + readonly transcribe: (uri: string, options: VoiceTranscriptionOptions) => Promise; +}; + +export type VoiceTranscriber = { + readonly prepare: (options: VoiceTranscriptionOptions) => Promise; +}; + +export type VoiceTranscriptionErrorCode = + | "unavailable" + | "unsupported-locale" + | "preparation-failed" + | "transcription-failed" + | "cancelled"; + +export class VoiceTranscriptionError extends Error { + readonly code: VoiceTranscriptionErrorCode; + + constructor(code: VoiceTranscriptionErrorCode, message: string, options?: ErrorOptions) { + super(message, options); + this.name = "VoiceTranscriptionError"; + this.code = code; + } +} + +export function throwIfVoiceTranscriptionAborted(signal: AbortSignal): void { + if (signal.aborted) { + throw new VoiceTranscriptionError("cancelled", "Voice transcription was cancelled."); + } +} diff --git a/packages/client-runtime/src/work-log/commandLabel.ts b/packages/client-runtime/src/work-log/commandLabel.ts new file mode 100644 index 000000000000..b0858725a3f7 --- /dev/null +++ b/packages/client-runtime/src/work-log/commandLabel.ts @@ -0,0 +1,169 @@ +type CommandWrapper = "env" | "sudo"; + +const COMMAND_WRAPPER_OPTIONS_WITH_VALUE: Record> = { + env: new Set(["-C", "--chdir", "-S", "--split-string", "-u", "--unset"]), + sudo: new Set(["-C", "--close-from", "-D", "--chdir", "-g", "--group", "-u", "--user"]), +}; + +const COMMAND_WRAPPER_FLAGS: Record> = { + env: new Set(["-0", "--null", "-i", "--ignore-environment", "--debug", "-v"]), + sudo: new Set(["-A", "--askpass", "-b", "--background", "-E", "-H", "-i", "-n", "-S"]), +}; + +function tokenizeShellCommand(command: string): string[] | null { + const input = command.trim(); + const tokens: string[] = []; + let current = ""; + let quote: '"' | "'" | null = null; + let escaping = false; + let substitutionDepth = 0; + let tokenStarted = false; + + for (let index = 0; index < input.length; index += 1) { + const character = input[index]!; + if (escaping) { + current += character; + escaping = false; + tokenStarted = true; + continue; + } + if (character === "\\" && quote !== "'") { + const nextCharacter = input[index + 1]; + const isWindowsDrivePath = quote === null && /^[A-Za-z]:/.test(current); + if ( + (quote === '"' || isWindowsDrivePath) && + nextCharacter !== undefined && + nextCharacter !== '"' && + nextCharacter !== "\\" && + nextCharacter !== "$" && + nextCharacter !== "`" && + nextCharacter !== "\n" + ) { + current += character; + tokenStarted = true; + continue; + } + escaping = true; + tokenStarted = true; + continue; + } + if (quote !== null) { + if (character === quote) { + quote = null; + } else { + current += character; + } + tokenStarted = true; + continue; + } + if (character === "$" && input[index + 1] === "(") { + current += "$("; + substitutionDepth += 1; + tokenStarted = true; + index += 1; + continue; + } + if (character === ")" && substitutionDepth > 0) { + current += character; + substitutionDepth -= 1; + tokenStarted = true; + continue; + } + if (character === '"' || character === "'") { + quote = character; + tokenStarted = true; + continue; + } + if (/\s/u.test(character)) { + if (substitutionDepth > 0) { + current += character; + tokenStarted = true; + continue; + } + if (tokenStarted) { + tokens.push(current); + current = ""; + tokenStarted = false; + } + continue; + } + current += character; + tokenStarted = true; + } + + if (quote !== null || escaping || substitutionDepth > 0) return null; + if (tokenStarted) tokens.push(current); + return tokens; +} + +export function commandProgramName(command: string, depth = 0): string | null { + if (depth >= 8) return null; + const tokens = tokenizeShellCommand(command); + if (tokens === null) return null; + let index = 0; + let wrapper: CommandWrapper | null = null; + + while (index < tokens.length) { + const token = tokens[index]; + if (!token) return null; + if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(token)) { + index += 1; + continue; + } + const tokenProgram = token.split(/[\\/]/).at(-1); + if (tokenProgram === "env" || tokenProgram === "sudo") { + wrapper = tokenProgram; + index += 1; + continue; + } + if (wrapper !== null && token === "--") { + wrapper = null; + index += 1; + continue; + } + if (wrapper !== null && token.startsWith("-")) { + if (wrapper === "env" && (token === "-S" || token === "--split-string")) { + const splitCommand = tokens[index + 1]; + return splitCommand ? commandProgramName(splitCommand, depth + 1) : null; + } + if (wrapper === "env" && token.startsWith("--split-string=")) { + return commandProgramName(token.slice("--split-string=".length), depth + 1); + } + if (COMMAND_WRAPPER_OPTIONS_WITH_VALUE[wrapper].has(token)) { + if (tokens[index + 1] === undefined) return null; + index += 2; + continue; + } + if (COMMAND_WRAPPER_FLAGS[wrapper].has(token)) { + index += 1; + continue; + } + const equalsIndex = token.indexOf("="); + if (token.startsWith("--") && equalsIndex > 2) { + if (!COMMAND_WRAPPER_OPTIONS_WITH_VALUE[wrapper].has(token.slice(0, equalsIndex))) { + return null; + } + index += 1; + continue; + } + if (/^-[A-Za-z].+/.test(token) && !token.startsWith("--")) { + let consumesNextToken = false; + for (const [optionIndex, option] of token.slice(1).split("").entries()) { + const shortOption = `-${option}`; + if (COMMAND_WRAPPER_OPTIONS_WITH_VALUE[wrapper].has(shortOption)) { + consumesNextToken = optionIndex === token.length - 2; + break; + } + if (!COMMAND_WRAPPER_FLAGS[wrapper].has(shortOption)) return null; + } + if (consumesNextToken && tokens[index + 1] === undefined) return null; + index += consumesNextToken ? 2 : 1; + continue; + } + return null; + } + return token.split(/[\\/]/).at(-1) || null; + } + + return null; +} diff --git a/packages/client-runtime/src/work-log/presentation.test.ts b/packages/client-runtime/src/work-log/presentation.test.ts new file mode 100644 index 000000000000..b4abd4c40199 --- /dev/null +++ b/packages/client-runtime/src/work-log/presentation.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { ThreadId } from "@t3tools/contracts"; + +import { resolveViewedImageAsset, workEntryViewedImagePath } from "./presentation.js"; + +describe("workEntryViewedImagePath", () => { + const entry = { label: "Read", tone: "tool" } as const; + + it("returns a single image path from supported read entries", () => { + expect( + workEntryViewedImagePath({ ...entry, requestKind: "file-read", detail: " assets/a.png " }), + ).toBe("assets/a.png"); + expect( + workEntryViewedImagePath({ + ...entry, + itemType: "dynamic_tool_call", + toolTitle: "Read file", + detail: "C:\\workspace\\a.webp", + }), + ).toBe("C:\\workspace\\a.webp"); + }); + + it("rejects non-image, multi-line, and non-read details", () => { + expect( + workEntryViewedImagePath({ ...entry, itemType: "image_view", detail: "a.txt" }), + ).toBeNull(); + expect( + workEntryViewedImagePath({ ...entry, itemType: "image_view", detail: "a.png\nb.png" }), + ).toBeNull(); + expect(workEntryViewedImagePath({ ...entry, detail: "a.png" })).toBeNull(); + }); +}); + +describe("resolveViewedImageAsset", () => { + const threadId = ThreadId.make("thread-1"); + + it("loads t3 attachment paths as attachments", () => { + const attachmentId = + "11111111-1111-4111-8111-111111111111-22222222-2222-4222-8222-222222222222"; + expect( + resolveViewedImageAsset(`/Users/demo/.t3/dev/attachments/${attachmentId}.png`, { + threadId, + workspaceRoot: "/workspace", + }), + ).toEqual({ + resource: { _tag: "attachment", attachmentId }, + alt: `${attachmentId}.png`, + srcFragment: "", + }); + }); + + it("normalizes workspace image sources", () => { + expect( + resolveViewedImageAsset("screens/logo.svg?v=2#mark", { + threadId, + workspaceRoot: "/workspace", + }), + ).toEqual({ + resource: { + _tag: "media-file", + threadId, + path: "/workspace/screens/logo.svg", + }, + alt: "logo.svg", + srcFragment: "#mark", + }); + expect(resolveViewedImageAsset("https://example.com/logo.png", { threadId })).toBeNull(); + }); +}); diff --git a/packages/client-runtime/src/work-log/presentation.ts b/packages/client-runtime/src/work-log/presentation.ts new file mode 100644 index 000000000000..2d5e6ebfc867 --- /dev/null +++ b/packages/client-runtime/src/work-log/presentation.ts @@ -0,0 +1,251 @@ +import { + isToolLifecycleItemType, + type AssetResource, + type ThreadId, + type ToolLifecycleItemType, +} from "@t3tools/contracts"; +import { + classifyMarkdownImageSource, + markdownImageSourceFragment, +} from "@t3tools/client-runtime/markdown-images"; +import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview"; + +export function isWorktreeSetupActivity(kind: string): boolean { + return kind === "setup-script.requested" || kind === "setup-script.started"; +} + +export interface WorkLogPresentationEntry { + readonly label: string; + readonly toolTitle?: string; + readonly tone: "thinking" | "tool" | "info" | "error"; + readonly command?: string; + readonly detail?: string; + readonly changedFiles?: ReadonlyArray; + readonly itemType?: ToolLifecycleItemType; + readonly requestKind?: string; + readonly turnId?: string | null; + readonly toolCallId?: string; + readonly toolLifecycleStatus?: string; + readonly sourceActivityKind?: string; + readonly taskId?: string; +} + +export type ToolGroupAction = + | "read" + | "edit" + | "command" + | "code-search" + | "search" + | "other" + | "update"; + +export type ToolGroupSummaryKind = + | ToolGroupAction + | "dynamic-tool" + | "agent-tool" + | "tone-tool" + | "mixed"; + +export function normalizeCompactToolLabel(value: string): string { + return value.replace(/\s+(?:complete|completed)\s*$/i, "").trim(); +} + +function workLogEntryIsToolLike(entry: WorkLogPresentationEntry): boolean { + if (entry.tone === "tool" || entry.tone === "thinking" || entry.tone === "error") return true; + if (entry.command !== undefined && entry.command.trim().length > 0) return true; + if (entry.requestKind !== undefined) return true; + return entry.itemType !== undefined && isToolLifecycleItemType(entry.itemType); +} + +export function workLogEntryIsLocalCodeSearch(entry: WorkLogPresentationEntry): boolean { + return ( + entry.itemType === "web_search" && + /\bgrep\b/i.test(normalizeCompactToolLabel(entry.toolTitle ?? entry.label)) + ); +} + +export function toolGroupAction(entry: WorkLogPresentationEntry): ToolGroupAction { + if ( + entry.requestKind === "file-read" || + entry.itemType === "image_view" || + (entry.itemType === "dynamic_tool_call" && + entry.toolTitle?.trim().toLowerCase() === "read file") + ) { + return "read"; + } + if ( + entry.requestKind === "file-change" || + entry.itemType === "file_change" || + (entry.changedFiles?.length ?? 0) > 0 + ) { + return "edit"; + } + if (entry.requestKind === "command" || entry.itemType === "command_execution" || entry.command) { + return "command"; + } + if (workLogEntryIsLocalCodeSearch(entry)) return "code-search"; + if (entry.itemType === "web_search") return "search"; + return workLogEntryIsToolLike(entry) ? "other" : "update"; +} + +export function workEntryViewedImagePath(entry: WorkLogPresentationEntry): string | null { + const detail = entry.detail?.trim(); + return toolGroupAction(entry) === "read" && + detail !== undefined && + !/[\r\n]/.test(detail) && + isWorkspaceImagePreviewPath(detail) + ? detail + : null; +} + +export interface ViewedImageAsset { + readonly resource: Extract; + readonly alt: string; + readonly srcFragment: string; +} + +const ABSOLUTE_IMAGE_SOURCE_PATTERN = /^(?:file:|[\\/]|[a-z]:[\\/])/i; +const T3_ATTACHMENT_IMAGE_PATH_PATTERN = + /(?:^|[\\/])(?:dev|userdata)[\\/]attachments[\\/]([a-z0-9_-]{1,128})\.[a-z0-9]{1,10}$/i; + +export function resolveViewedImageAsset( + source: string, + input: { + readonly threadId: ThreadId; + readonly workspaceRoot?: string | null | undefined; + }, +): ViewedImageAsset | null { + const imageSource = classifyMarkdownImageSource(source, input.workspaceRoot ?? "."); + if (imageSource._tag !== "WorkspaceFile") return null; + + const path = + input.workspaceRoot == null && imageSource.path.startsWith("./") + ? imageSource.path.slice(2) + : imageSource.path; + const attachmentId = ABSOLUTE_IMAGE_SOURCE_PATTERN.test(source) + ? (T3_ATTACHMENT_IMAGE_PATH_PATTERN.exec(path)?.[1] ?? null) + : null; + + return { + resource: attachmentId + ? { _tag: "attachment", attachmentId } + : { _tag: "media-file", threadId: input.threadId, path }, + alt: path.split(/[\\/]/).at(-1) ?? "image", + srcFragment: markdownImageSourceFragment(source), + }; +} + +function toolGroupActionCount( + action: ToolGroupAction, + entries: ReadonlyArray, +): number { + if (action !== "edit") return entries.length; + + const changedFiles = new Set(); + let editsWithoutFileDetails = 0; + for (const entry of entries) { + if (!entry.changedFiles || entry.changedFiles.length === 0) { + editsWithoutFileDetails += 1; + continue; + } + for (const file of entry.changedFiles) changedFiles.add(file); + } + return changedFiles.size + editsWithoutFileDetails; +} + +function toolGroupActionLabel(action: ToolGroupAction, count: number): string { + switch (action) { + case "read": + return `Read ${count} ${count === 1 ? "file" : "files"}`; + case "edit": + return `Changed ${count} ${count === 1 ? "file" : "files"}`; + case "command": + return `Ran ${count} ${count === 1 ? "command" : "commands"}`; + case "search": + return `Searched the web ${count} ${count === 1 ? "time" : "times"}`; + case "code-search": + return `Searched code ${count} ${count === 1 ? "time" : "times"}`; + case "other": + return `Used ${count} ${count === 1 ? "tool" : "tools"}`; + case "update": + return `Received ${count} ${count === 1 ? "update" : "updates"}`; + } +} + +export function summarizeToolGroup(entries: ReadonlyArray): string { + const summaryEntries = omitSupersededLifecycleMarkers(entries, (entry) => entry); + const groupedEntries = new Map(); + for (const entry of summaryEntries) { + const action = toolGroupAction(entry); + const group = groupedEntries.get(action); + if (group) group.push(entry); + else groupedEntries.set(action, [entry]); + } + const labels = [...groupedEntries].map(([action, actionEntries]) => + toolGroupActionLabel(action, toolGroupActionCount(action, actionEntries)), + ); + const sentenceLabels = labels.map((label, index) => + index === 0 ? label : label.charAt(0).toLowerCase() + label.slice(1), + ); + if (sentenceLabels.length < 2) return sentenceLabels[0] ?? ""; + if (sentenceLabels.length === 2) return sentenceLabels.join(" and "); + return `${sentenceLabels.slice(0, -1).join(", ")}, and ${sentenceLabels.at(-1)}`; +} + +export function omitSupersededLifecycleMarkers( + entries: readonly T[], + workEntryFor: (entry: T) => WorkLogPresentationEntry, +): T[] { + const laterTerminalIdentities = new Set(); + const reversedEntries: T[] = []; + + for (let index = entries.length - 1; index >= 0; index -= 1) { + const entry = entries[index]!; + const workEntry = workEntryFor(entry); + const normalizedLabel = normalizeCompactToolLabel(workEntry.toolTitle ?? workEntry.label); + const identity = [ + workEntry.turnId ?? "no-turn", + workEntry.itemType ?? "", + normalizedLabel, + ].join("\u001f"); + const activityKind = workEntry.sourceActivityKind; + const isStatuslessIdlessMarker = + workEntry.toolCallId === undefined && + workEntry.toolLifecycleStatus === undefined && + (activityKind === "tool.started" || activityKind === "tool.updated"); + if (isStatuslessIdlessMarker && laterTerminalIdentities.has(identity)) continue; + + reversedEntries.push(entry); + if ( + activityKind === "tool.completed" || + (workEntry.toolLifecycleStatus !== undefined && + workEntry.toolLifecycleStatus !== "inProgress") + ) { + laterTerminalIdentities.add(identity); + } + } + + return reversedEntries.toReversed(); +} + +export function toolGroupSummaryKind( + entries: ReadonlyArray, +): ToolGroupSummaryKind { + const actions = new Set(entries.map(toolGroupAction)); + if (actions.size !== 1) return "mixed"; + + const action = actions.values().next().value!; + if (action !== "other") return action; + + const fallbackKinds = new Set( + entries.map((entry): ToolGroupSummaryKind => { + if (entry.itemType === "mcp_tool_call") return "other"; + if (entry.itemType === "dynamic_tool_call") return "dynamic-tool"; + if (entry.itemType === "collab_agent_tool_call" || entry.taskId) return "agent-tool"; + if (entry.tone === "thinking") return "agent-tool"; + if (entry.tone === "tool") return "tone-tool"; + return "other"; + }), + ); + return fallbackKinds.size === 1 ? fallbackKinds.values().next().value! : "mixed"; +} diff --git a/packages/contracts/package.json b/packages/contracts/package.json index d19d90f15525..dbd8ee3744e8 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/contracts", - "version": "0.0.33", + "version": "0.0.38", "private": true, "files": [ "dist" diff --git a/packages/contracts/src/assets.test.ts b/packages/contracts/src/assets.test.ts index ce4214d300da..c47c53b2a84e 100644 --- a/packages/contracts/src/assets.test.ts +++ b/packages/contracts/src/assets.test.ts @@ -2,7 +2,10 @@ import * as Schema from "effect/Schema"; import { describe, expect, it } from "vite-plus/test"; import { AttachmentCreateUploadUrlInput } from "./assets.ts"; -import { PROVIDER_SEND_TURN_MAX_IMAGE_BYTES } from "./orchestration.ts"; +import { + PROVIDER_SEND_TURN_MAX_FILE_BYTES, + PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, +} from "./orchestration.ts"; const isUploadInput = Schema.is(AttachmentCreateUploadUrlInput); @@ -21,10 +24,37 @@ describe("AttachmentCreateUploadUrlInput", () => { expect(isUploadInput({ ...uploadInput, mimeType: "image/svg+xml" })).toBe(false); }); + it("accepts generic files without treating them as provider images", () => { + expect( + isUploadInput({ + type: "file", + name: "report.pdf", + mimeType: "application/pdf", + sizeBytes: PROVIDER_SEND_TURN_MAX_IMAGE_BYTES + 1, + }), + ).toBe(true); + expect( + isUploadInput({ + type: "file", + name: "diagram.svg", + mimeType: "image/svg+xml", + sizeBytes: 3, + }), + ).toBe(true); + }); + it("rejects empty and oversized uploads", () => { expect(isUploadInput({ ...uploadInput, sizeBytes: 0 })).toBe(false); expect( isUploadInput({ ...uploadInput, sizeBytes: PROVIDER_SEND_TURN_MAX_IMAGE_BYTES + 1 }), ).toBe(false); + expect( + isUploadInput({ + type: "file", + name: "archive.zip", + mimeType: "application/zip", + sizeBytes: PROVIDER_SEND_TURN_MAX_FILE_BYTES + 1, + }), + ).toBe(false); }); }); diff --git a/packages/contracts/src/assets.ts b/packages/contracts/src/assets.ts index bfc2c9472aaa..5a949fea38bf 100644 --- a/packages/contracts/src/assets.ts +++ b/packages/contracts/src/assets.ts @@ -2,6 +2,7 @@ import * as Schema from "effect/Schema"; import { NonNegativeInt, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; import { + PROVIDER_SEND_TURN_MAX_FILE_BYTES, PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, PROVIDER_SEND_TURN_SUPPORTED_IMAGE_MIME_TYPES, ProjectFaviconPath, @@ -14,8 +15,18 @@ export const AssetResource = Schema.Union([ threadId: ThreadId, path: TrimmedNonEmptyString.check(Schema.isMaxLength(ASSET_PATH_MAX_LENGTH)), }), + Schema.TaggedStruct("media-file", { + threadId: ThreadId, + path: TrimmedNonEmptyString.check(Schema.isMaxLength(ASSET_PATH_MAX_LENGTH)), + }), Schema.TaggedStruct("attachment", { attachmentId: TrimmedNonEmptyString.check(Schema.isMaxLength(256)), + /** Display name and mime from the `ChatAttachment` the caller holds. The + server bakes both into the signed URL so downloads carry the real + filename and Content-Type. Absent on older clients, which fall back to + an octet-stream download without a filename. */ + fileName: Schema.optionalKey(TrimmedNonEmptyString.check(Schema.isMaxLength(255))), + mimeType: Schema.optionalKey(TrimmedNonEmptyString.check(Schema.isMaxLength(100))), }), Schema.TaggedStruct("project-favicon", { cwd: TrimmedNonEmptyString.check(Schema.isMaxLength(ASSET_PATH_MAX_LENGTH)), @@ -42,7 +53,8 @@ export type AssetCreateUrlResult = typeof AssetCreateUrlResult.Type; export const ATTACHMENT_UPLOAD_URL_TTL_MS = 10 * 60_000; -export const AttachmentCreateUploadUrlInput = Schema.Struct({ +const ImageAttachmentCreateUploadUrlInput = Schema.Struct({ + type: Schema.optionalKey(Schema.Literal("image")), name: TrimmedNonEmptyString.check(Schema.isMaxLength(255)), mimeType: Schema.Literals(PROVIDER_SEND_TURN_SUPPORTED_IMAGE_MIME_TYPES), sizeBytes: NonNegativeInt.check( @@ -50,6 +62,21 @@ export const AttachmentCreateUploadUrlInput = Schema.Struct({ Schema.isLessThanOrEqualTo(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES), ), }); + +const FileAttachmentCreateUploadUrlInput = Schema.Struct({ + type: Schema.Literal("file"), + name: TrimmedNonEmptyString.check(Schema.isMaxLength(255)), + mimeType: TrimmedNonEmptyString.check(Schema.isMaxLength(100)), + sizeBytes: NonNegativeInt.check( + Schema.isGreaterThanOrEqualTo(1), + Schema.isLessThanOrEqualTo(PROVIDER_SEND_TURN_MAX_FILE_BYTES), + ), +}); + +export const AttachmentCreateUploadUrlInput = Schema.Union([ + ImageAttachmentCreateUploadUrlInput, + FileAttachmentCreateUploadUrlInput, +]); export type AttachmentCreateUploadUrlInput = typeof AttachmentCreateUploadUrlInput.Type; export const AttachmentCreateUploadUrlResult = Schema.Struct({ @@ -129,7 +156,9 @@ export class AssetPreviewTypeValidationError extends Schema.TaggedErrorClass e.id)); export type EditorId = typeof EditorId.Type; +export const FileManagerRevealKind = Schema.Literals(["finder", "file-explorer", "files"]); +export type FileManagerRevealKind = typeof FileManagerRevealKind.Type; + export const LaunchEditorInput = Schema.Struct({ cwd: TrimmedNonEmptyString, editor: EditorId, + /** Reveal (select) `cwd` in the file manager instead of opening it. Only + honored by the "file-manager" editor; clients must check the server's + `shellRevealInFileManager` config flag before sending this. */ + reveal: Schema.optional(Schema.Boolean), }); export type LaunchEditorInput = typeof LaunchEditorInput.Type; diff --git a/packages/contracts/src/environment.test.ts b/packages/contracts/src/environment.test.ts index 455cc58f47d1..55633835bba1 100644 --- a/packages/contracts/src/environment.test.ts +++ b/packages/contracts/src/environment.test.ts @@ -39,4 +39,16 @@ describe("ExecutionEnvironmentDescriptor", () => { }).capabilities.attachmentUploads, ).toBe(true); }); + + it("preserves the server's generic attachment upload limit", () => { + expect( + decodeDescriptor({ + ...descriptor, + capabilities: { + ...descriptor.capabilities, + fileAttachments: { maxUploadBytes: 50 * 1024 * 1024 }, + }, + }).capabilities.fileAttachments, + ).toEqual({ maxUploadBytes: 50 * 1024 * 1024 }); + }); }); diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index 1468fe9ef3d3..08e82599020a 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -50,6 +50,12 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ connectionProbe: Schema.optionalKey(Schema.Boolean), /** Missing on older servers, which still accept inline image attachments. */ attachmentUploads: Schema.optionalKey(Schema.Boolean), + /** Missing on servers that only accept image attachments. */ + fileAttachments: Schema.optionalKey( + Schema.Struct({ + maxUploadBytes: Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)), + }), + ), /** Server exposes the pull-request list, detail, activity, diff, and mutation APIs. Absent on servers from before the pull-request workspace shipped, so clients must not probe them. */ pullRequests: Schema.optionalKey(Schema.Boolean), @@ -57,9 +63,16 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ pre-settlement servers, so clients treat missing as unsupported and never send the commands under version skew. */ threadSettlement: Schema.optionalKey(Schema.Boolean), + /** Server evaluates merge and inactivity settlement without a client. */ + threadAutoSettlement: Schema.optionalKey(Schema.Boolean), /** Server understands thread.snooze / thread.unsnooze commands. Same version-skew contract as threadSettlement. */ threadSnooze: Schema.optionalKey(Schema.Boolean), + /** Server streams themes an environment publishes. Absent on servers from + before environment themes shipped, which never emit the events -- so a + client reconnecting to one must drop published themes rather than keep + showing a set nothing will ever update. */ + environmentThemes: Schema.optionalKey(Schema.Boolean), /** Server understands thread.pin / thread.unpin commands. Same version-skew contract as threadSettlement. */ threadPinning: Schema.optionalKey(Schema.Boolean), @@ -69,6 +82,8 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ /** Server understands regenerateTitle on thread.meta.update. Absent on older servers, so clients hide the action instead of sending it. */ threadTitleRegeneration: Schema.optionalKey(Schema.Boolean), + /** Server persists a pull request reference on thread.meta.update. */ + threadPullRequestLinking: Schema.optionalKey(Schema.Boolean), /** The update path clients should offer for this server. Absent on servers that must be relaunched manually (dev checkouts, Windows foreground runs, pre-update servers). */ diff --git a/packages/contracts/src/environmentHttp.ts b/packages/contracts/src/environmentHttp.ts index e7494862251e..a895697e36b0 100644 --- a/packages/contracts/src/environmentHttp.ts +++ b/packages/contracts/src/environmentHttp.ts @@ -24,7 +24,12 @@ import { AuthWebSocketTicketResult, ServerAuthSessionMethod, } from "./auth.ts"; -import { AuthSessionId, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { + DpopFailureReason, + AuthSessionId, + ThreadId, + TrimmedNonEmptyString, +} from "./baseSchemas.ts"; import { ExecutionEnvironmentDescriptor } from "./environment.ts"; import { ClientOrchestrationCommand, @@ -117,6 +122,8 @@ export class EnvironmentAuthInvalidError extends Schema.TaggedErrorClass { id: T; @@ -116,6 +120,10 @@ export interface ContextMenuItem { children?: readonly ContextMenuItem[]; } +export type QuitShortcutHintEvent = + | { readonly state: "down"; readonly mode: Exclude } + | { readonly state: "up" }; + export interface ContextMenuItemSchemaType { readonly id: string; readonly label: string; @@ -190,12 +198,6 @@ export interface DesktopRuntimeInfo { runningUnderArm64Translation: boolean; } -export const DesktopRuntimeInfoSchema = Schema.Struct({ - hostArch: DesktopRuntimeArchSchema, - appArch: DesktopRuntimeArchSchema, - runningUnderArm64Translation: Schema.Boolean, -}); - export interface DesktopUpdateState { enabled: boolean; status: DesktopUpdateStatus; @@ -343,14 +345,6 @@ export interface DesktopSshPasswordPromptRequest { expiresAt: string; } -export const DesktopSshPasswordPromptRequestSchema = Schema.Struct({ - requestId: Schema.String, - destination: Schema.String, - username: Schema.NullOr(Schema.String), - prompt: Schema.String, - expiresAt: Schema.String, -}); - export const DesktopSshPasswordPromptCancelledType = "ssh-password-prompt-cancelled" as const; export const DesktopSshPasswordPromptCancelledResultSchema = Schema.Struct({ @@ -584,6 +578,12 @@ export const DesktopPreviewTabIdSchema = Schema.String.check(Schema.isTrimmed()) Schema.isNonEmpty(), ); +export const DesktopPreviewAutomationStatusSchema = Schema.Struct({ + ...PreviewAutomationStatus.fields, + tabId: Schema.NullOr(DesktopPreviewTabIdSchema), +}); +export type DesktopPreviewAutomationStatus = typeof DesktopPreviewAutomationStatusSchema.Type; + export const DesktopPreviewNavStatusSchema = Schema.Union([ Schema.Struct({ kind: Schema.Literal("Idle") }), Schema.Struct({ @@ -605,22 +605,6 @@ export const DesktopPreviewNavStatusSchema = Schema.Union([ }), ]); -export const DesktopPreviewTabStateSchema: Schema.Codec = Schema.Struct({ - tabId: DesktopPreviewTabIdSchema, - webContentsId: Schema.NullOr(Schema.Int), - navStatus: DesktopPreviewNavStatusSchema, - canGoBack: Schema.Boolean, - canGoForward: Schema.Boolean, - zoomFactor: Schema.Number, - pictureInPicture: Schema.Boolean, - colorScheme: DesktopPreviewColorSchemeSchema, - audioMuted: Schema.Boolean, - audible: Schema.Boolean, - controller: Schema.Literals(["human", "agent", "none"]), - favicon: Schema.optionalKey(DesktopPreviewFaviconSchema), - updatedAt: Schema.String, -}); - export interface DesktopPreviewPointerEvent { tabId: string; phase: "move" | "click"; @@ -630,16 +614,6 @@ export interface DesktopPreviewPointerEvent { createdAt: string; } -export const DesktopPreviewPointerEventSchema: Schema.Codec = - Schema.Struct({ - tabId: DesktopPreviewTabIdSchema, - phase: Schema.Literals(["move", "click"]), - x: Schema.Number, - y: Schema.Number, - sequence: Schema.Int, - createdAt: Schema.String, - }); - /** * Static config a renderer needs to mount a preview ``. Returned * atomically by `DesktopPreviewBridge.getPreviewConfig()` so the renderer @@ -719,15 +693,6 @@ export interface DesktopPreviewRecordingFrame { receivedAt: string; } -export const DesktopPreviewRecordingFrameSchema: Schema.Codec = - Schema.Struct({ - tabId: DesktopPreviewTabIdSchema, - data: Schema.String, - width: Schema.Number, - height: Schema.Number, - receivedAt: Schema.String, - }); - export interface DesktopPreviewRecordingArtifact { id: string; tabId: string; @@ -1063,6 +1028,8 @@ export const DesktopPreviewAutomationWaitForInputSchema = Schema.Struct({ export interface DesktopBridge { getAppBranding: () => DesktopAppBranding | null; + /** The desktop client's OS platform, read from Electron's preload process. */ + getClientPlatform?: () => string; /** * The OS locale as a BCP-47 tag, which the renderer cannot read for itself: * the packaged app ships only the `en-US` Chromium locale pak, so @@ -1132,11 +1099,10 @@ export interface DesktopBridge { probeRemoteEditors?: () => Promise; onMenuAction: (listener: (action: string) => void) => () => void; /** - * Hold-to-quit hint pushes: "down" when the quit shortcut is first pressed, - * "up" when it is released before the hold completes. Optional: older - * desktop builds never emit it. + * Quit-confirmation hint pushes. Optional: older desktop builds never emit + * them. */ - onQuitShortcut?: (listener: (state: "down" | "up") => void) => () => void; + onQuitShortcut?: (listener: (event: QuitShortcutHintEvent) => void) => () => void; getWindowFullscreenState: () => boolean; onWindowFullscreenStateChange: (listener: (fullscreen: boolean) => void) => () => void; getUpdateState: () => Promise; @@ -1145,6 +1111,12 @@ export interface DesktopBridge { downloadUpdate: () => Promise; installUpdate: () => Promise; onUpdateState: (listener: (state: DesktopUpdateState) => void) => () => void; + /** Present when the desktop shell accepts `t3 app` activation requests. */ + appActivation?: { + setReady: (ready: boolean) => Promise; + complete: (response: DesktopAppActivationResponse) => Promise; + onRequest: (listener: (request: DesktopAppActivationRequest) => void) => () => void; + }; /** * Desktop-only preview surface. Present iff the renderer is hosted by the * Electron desktop build; web builds have `preview === undefined`. @@ -1152,6 +1124,9 @@ export interface DesktopBridge { preview?: DesktopPreviewBridge; } +/** Renderer callback invoked by Electron with a fresh user gesture before display-media capture. */ +export const DESKTOP_PREVIEW_RECORDING_CAPTURE_TRIGGER = "__t3DesktopPreviewRecordingCapture"; + export interface DesktopPreviewBridge { createTab: (tabId: string, defaults?: DesktopPreviewTabDefaults) => Promise; closeTab: (tabId: string) => Promise; @@ -1217,7 +1192,7 @@ export interface DesktopPreviewBridge { onFrame: (listener: (frame: DesktopPreviewRecordingFrame) => void) => () => void; }; automation: { - status: (tabId: string) => Promise; + status: (tabId: string) => Promise; snapshot: (tabId: string) => Promise; click: (tabId: string, input: PreviewAutomationClickInput) => Promise; type: (tabId: string, input: PreviewAutomationTypeInput) => Promise; diff --git a/packages/contracts/src/keybindings.test.ts b/packages/contracts/src/keybindings.test.ts index 71d8624a8aea..411cff23c82e 100644 --- a/packages/contracts/src/keybindings.test.ts +++ b/packages/contracts/src/keybindings.test.ts @@ -107,6 +107,20 @@ it.effect("parses keybinding rules", () => command: "thread.previous", }); assert.strictEqual(parsedThreadPrevious.command, "thread.previous"); + + const parsedThreadSettle = yield* decode(KeybindingRule, { + key: "mod+shift+s", + command: "thread.settle", + when: "!terminalFocus", + }); + assert.strictEqual(parsedThreadSettle.command, "thread.settle"); + + const parsedThreadCopyReference = yield* decode(KeybindingRule, { + key: "mod+shift+c", + command: "thread.copyReference", + when: "!terminalFocus", + }); + assert.strictEqual(parsedThreadCopyReference.command, "thread.copyReference"); }), ); diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index 19276c41e7b6..fe3549d079a7 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -37,6 +37,9 @@ export type ModelPickerJumpKeybindingCommand = export const THREAD_KEYBINDING_COMMANDS = [ "thread.previous", "thread.next", + "thread.copyReference", + "thread.settle", + "thread.pin", ...THREAD_JUMP_KEYBINDING_COMMANDS, ] as const; export type ThreadKeybindingCommand = (typeof THREAD_KEYBINDING_COMMANDS)[number]; diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index 9fcd0d266dd6..92080d0cb4ab 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -176,30 +176,7 @@ export const MODEL_SLUG_ALIASES_BY_PROVIDER: Partial< "5.3-spark": "gpt-5.3-codex-spark", "gpt-5.3-spark": "gpt-5.3-codex-spark", }, - [CLAUDE_DRIVER_KIND]: { - opus: "claude-opus-5", - "opus-5": "claude-opus-5", - "claude-opus-5.0": "claude-opus-5", - "claude-opus-5-0": "claude-opus-5", - "opus-4.8": "claude-opus-4-8", - "claude-opus-4.8": "claude-opus-4-8", - "opus-4.7": "claude-opus-4-7", - "claude-opus-4.7": "claude-opus-4-7", - "opus-4.6": "claude-opus-4-6", - "claude-opus-4.6": "claude-opus-4-6", - "claude-opus-4-6-20251117": "claude-opus-4-6", - sonnet: "claude-sonnet-5", - "sonnet-5": "claude-sonnet-5", - "claude-sonnet-5.0": "claude-sonnet-5", - "claude-sonnet-5-0": "claude-sonnet-5", - "sonnet-4.6": "claude-sonnet-4-6", - "claude-sonnet-4.6": "claude-sonnet-4-6", - "claude-sonnet-4-6-20251117": "claude-sonnet-4-6", - haiku: "claude-haiku-4-5", - "haiku-4.5": "claude-haiku-4-5", - "claude-haiku-4.5": "claude-haiku-4-5", - "claude-haiku-4-5-20251001": "claude-haiku-4-5", - }, + [CLAUDE_DRIVER_KIND]: {}, [CURSOR_DRIVER_KIND]: { composer: "composer-2", "composer-1.5": "composer-1.5", diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts index 27bdecdda7a8..4ae91d27c184 100644 --- a/packages/contracts/src/orchestration.test.ts +++ b/packages/contracts/src/orchestration.test.ts @@ -1,5 +1,6 @@ import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as Schema from "effect/Schema"; import { @@ -20,12 +21,15 @@ import { OrchestrationThread, OrchestrationThreadShell, ProjectCreateCommand, + OrchestrationMessage, + ThreadMessageSentPayload, ThreadMetaUpdatedPayload, ThreadTurnStartCommand, ThreadCreatedPayload, ThreadTurnDiff, ThreadTurnStartRequestedPayload, isProviderSendTurnSupportedImageMimeType, + PROVIDER_SEND_TURN_MAX_FILE_BYTES, } from "./orchestration.ts"; import { ProviderInstanceId } from "./providerInstance.ts"; @@ -37,6 +41,8 @@ const decodeProjectCreatedPayload = Schema.decodeUnknownEffect(ProjectCreatedPay const decodeProjectMetaUpdatedPayload = Schema.decodeUnknownEffect(ProjectMetaUpdatedPayload); const decodeThreadTurnStartCommand = Schema.decodeUnknownEffect(ThreadTurnStartCommand); const decodeClientOrchestrationCommand = Schema.decodeUnknownEffect(ClientOrchestrationCommand); +const decodeOrchestrationMessage = Schema.decodeUnknownEffect(OrchestrationMessage); +const decodeThreadMessageSentPayload = Schema.decodeUnknownEffect(ThreadMessageSentPayload); const decodeThreadTurnStartRequestedPayload = Schema.decodeUnknownEffect( ThreadTurnStartRequestedPayload, ); @@ -243,7 +249,7 @@ it.effect("decodes thread.turn.start defaults for provider and runtime mode", () }), ); -it.effect("accepts both inline and uploaded image attachments from clients", () => +it.effect("accepts inline images, uploaded images, and uploaded files from clients", () => Effect.gen(function* () { const command = yield* decodeClientOrchestrationCommand({ type: "thread.turn.start", @@ -268,6 +274,13 @@ it.effect("accepts both inline and uploaded image attachments from clients", () mimeType: "image/png", sizeBytes: 3, }, + { + type: "file", + id: "pending-00000000-0000-4000-8000-000000000002-pdf", + name: "report.pdf", + mimeType: "application/pdf", + sizeBytes: 3, + }, ], }, runtimeMode: "full-access", @@ -278,9 +291,85 @@ it.effect("accepts both inline and uploaded image attachments from clients", () if (command.type !== "thread.turn.start") { assert.fail(`Expected thread.turn.start, received ${command.type}.`); } - assert.strictEqual(command.message.attachments.length, 2); + assert.strictEqual(command.message.attachments.length, 3); assert.strictEqual("dataUrl" in command.message.attachments[0]!, true); assert.strictEqual("id" in command.message.attachments[1]!, true); + assert.strictEqual(command.message.attachments[2]!.type, "file"); + }), +); + +// Attachments ride on persisted events and thread streams with no client +// version negotiation. A type this build does not know must decode instead of +// failing the whole message. +it.effect("tolerates attachment types from newer builds when decoding messages", () => + Effect.gen(function* () { + const futureAttachment = { + type: "somethingnew", + id: "thread-1-00000000-0000-4000-8000-000000000003-glb", + name: "scene.glb", + mimeType: "model/gltf-binary", + sizeBytes: 12, + }; + + const message = yield* decodeOrchestrationMessage({ + id: "message-1", + role: "user", + text: "look at this", + attachments: [futureAttachment], + turnId: null, + streaming: false, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }); + assert.strictEqual(message.attachments?.length, 1); + assert.strictEqual(message.attachments?.[0]!.type, "somethingnew"); + + const payload = yield* decodeThreadMessageSentPayload({ + threadId: "thread-1", + messageId: "message-1", + role: "user", + text: "look at this", + attachments: [futureAttachment], + turnId: null, + streaming: false, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }); + assert.strictEqual(payload.attachments?.[0]!.type, "somethingnew"); + }), +); + +// The tolerant member must not catch malformed known attachments: a file over +// the size cap or an image with a bad mime has to fail its own schema, not +// slide through the open one with those constraints unchecked. +it.effect("rejects malformed known attachment types instead of tolerating them", () => + Effect.gen(function* () { + const base = { + id: "thread-1-00000000-0000-4000-8000-000000000003-pdf", + name: "report.pdf", + mimeType: "application/pdf", + }; + const decode = (attachment: unknown) => + decodeOrchestrationMessage({ + id: "message-1", + role: "user", + text: "look at this", + attachments: [attachment], + turnId: null, + streaming: false, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }); + + const oversizedFile = yield* Effect.exit( + decode({ ...base, type: "file", sizeBytes: PROVIDER_SEND_TURN_MAX_FILE_BYTES + 1 }), + ); + assert.strictEqual(Exit.isFailure(oversizedFile), true); + + const badMimeImage = yield* Effect.exit( + decode({ ...base, type: "image", mimeType: "application/pdf", sizeBytes: 12 }), + ); + assert.strictEqual(Exit.isFailure(badMimeImage), true); }), ); @@ -709,6 +798,28 @@ it.effect("accepts a title regeneration intent in thread.meta.update", () => }), ); +it.effect("accepts a linked pull request in thread.meta.update", () => + Effect.gen(function* () { + const linkedPullRequest = { + projectId: "project-1", + repository: "pingdotgg/t3code", + number: 42, + url: "https://github.com/pingdotgg/t3code/pull/42", + }; + const parsed = yield* decodeOrchestrationCommand({ + type: "thread.meta.update", + commandId: "cmd-link-pull-request", + threadId: "thread-1", + linkedPullRequest, + }); + + assert.strictEqual(parsed.type, "thread.meta.update"); + if (parsed.type === "thread.meta.update") { + assert.deepStrictEqual(parsed.linkedPullRequest, linkedPullRequest); + } + }), +); + it.effect("accepts an internal title regeneration completion", () => Effect.gen(function* () { const parsed = yield* decodeOrchestrationCommand({ diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index af4fefaccf59..bbfaa1b595c2 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -156,6 +156,7 @@ export type ProviderUserInputAnswers = typeof ProviderUserInputAnswers.Type; export const PROVIDER_SEND_TURN_MAX_INPUT_CHARS = 120_000; export const PROVIDER_SEND_TURN_MAX_ATTACHMENTS = 8; export const PROVIDER_SEND_TURN_MAX_IMAGE_BYTES = 10 * 1024 * 1024; +export const PROVIDER_SEND_TURN_MAX_FILE_BYTES = 50 * 1024 * 1024; export const PROVIDER_SEND_TURN_SUPPORTED_IMAGE_MIME_TYPES = [ "image/gif", "image/jpeg", @@ -191,6 +192,40 @@ export const ChatImageAttachment = Schema.Struct({ }); export type ChatImageAttachment = typeof ChatImageAttachment.Type; +export const ChatFileAttachment = Schema.Struct({ + type: Schema.Literal("file"), + id: ChatAttachmentId, + name: TrimmedNonEmptyString.check(Schema.isMaxLength(255)), + mimeType: TrimmedNonEmptyString.check(Schema.isMaxLength(100)), + sizeBytes: NonNegativeInt.check( + Schema.isGreaterThanOrEqualTo(1), + Schema.isLessThanOrEqualTo(PROVIDER_SEND_TURN_MAX_FILE_BYTES), + ), +}); +export type ChatFileAttachment = typeof ChatFileAttachment.Type; + +/** + * Catch-all for attachment types this build does not know. Attachments ride on + * persisted events and thread streams, so a newer server or client must be able + * to introduce a type without making older readers fail to decode the whole + * message. Decoders keep the shared base fields; consumers skip these or render + * them as unsupported. Mirrors how `OrchestrationThreadActivity` keeps `kind` + * open. The known discriminators are excluded so a malformed image or file + * attachment fails its own schema instead of sliding through here with its + * size and mime constraints unchecked. + */ +export const ChatUnknownAttachment = Schema.Struct({ + type: TrimmedNonEmptyString.check( + Schema.isMaxLength(50), + Schema.isPattern(/^(?!(?:image|file)$)/), + ), + id: ChatAttachmentId, + name: TrimmedNonEmptyString.check(Schema.isMaxLength(255)), + mimeType: TrimmedNonEmptyString.check(Schema.isMaxLength(100)), + sizeBytes: NonNegativeInt, +}); +export type ChatUnknownAttachment = typeof ChatUnknownAttachment.Type; + const UploadChatImageAttachment = Schema.Struct({ type: Schema.Literal("image"), name: TrimmedNonEmptyString.check(Schema.isMaxLength(255)), @@ -202,7 +237,11 @@ const UploadChatImageAttachment = Schema.Struct({ }); export type UploadChatImageAttachment = typeof UploadChatImageAttachment.Type; -export const ChatAttachment = Schema.Union([ChatImageAttachment]); +export const ChatAttachment = Schema.Union([ + ChatImageAttachment, + ChatFileAttachment, + ChatUnknownAttachment, +]); export type ChatAttachment = typeof ChatAttachment.Type; const UploadChatAttachment = Schema.Union([UploadChatImageAttachment]); export type UploadChatAttachment = typeof UploadChatAttachment.Type; @@ -387,6 +426,14 @@ export const ThreadTitleRegeneration = Schema.Struct({ }); export type ThreadTitleRegeneration = typeof ThreadTitleRegeneration.Type; +export const ThreadLinkedPullRequest = Schema.Struct({ + projectId: ProjectId, + repository: TrimmedNonEmptyString, + number: PositiveInt, + url: TrimmedNonEmptyString, +}); +export type ThreadLinkedPullRequest = typeof ThreadLinkedPullRequest.Type; + export const OrchestrationThread = Schema.Struct({ id: ThreadId, projectId: ProjectId, @@ -398,6 +445,7 @@ export const OrchestrationThread = Schema.Struct({ ), branch: Schema.NullOr(TrimmedNonEmptyString), worktreePath: Schema.NullOr(TrimmedNonEmptyString), + linkedPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)), latestTurn: Schema.NullOr(OrchestrationLatestTurn), createdAt: IsoDateTime, updatedAt: IsoDateTime, @@ -406,6 +454,11 @@ export const OrchestrationThread = Schema.Struct({ Schema.withDecodingDefault(Effect.succeed(null)), ), settledAt: Schema.NullOr(IsoDateTime).pipe(Schema.withDecodingDefault(Effect.succeed(null))), + // When the thread last re-entered the active list (any thread.unsettled). + // Anchors the active-list sort so an unsettled thread surfaces at the top + // instead of sinking back to its creation-order slot. Cleared on settle. + // Optional so payloads from pre-stamp servers still decode. + unsettledAt: Schema.optional(Schema.NullOr(IsoDateTime)), // Snooze is an overlay on the active lifecycle, not a fourth destination: // a snoozed thread stays "active" in the model and is only suppressed from // the inbox until snoozedUntil passes (or the thread raises its hand). @@ -468,6 +521,7 @@ export const OrchestrationThreadShell = Schema.Struct({ ), branch: Schema.NullOr(TrimmedNonEmptyString), worktreePath: Schema.NullOr(TrimmedNonEmptyString), + linkedPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)), latestTurn: Schema.NullOr(OrchestrationLatestTurn), createdAt: IsoDateTime, updatedAt: IsoDateTime, @@ -476,6 +530,8 @@ export const OrchestrationThreadShell = Schema.Struct({ Schema.withDecodingDefault(Effect.succeed(null)), ), settledAt: Schema.NullOr(IsoDateTime).pipe(Schema.withDecodingDefault(Effect.succeed(null))), + // See OrchestrationThread.unsettledAt: last re-entry into the active list. + unsettledAt: Schema.optional(Schema.NullOr(IsoDateTime)), snoozedUntil: Schema.optional(Schema.NullOr(IsoDateTime)), snoozedAt: Schema.optional(Schema.NullOr(IsoDateTime)), pinnedAt: Schema.optional(Schema.NullOr(IsoDateTime)), @@ -716,6 +772,13 @@ const ThreadSettleCommand = Schema.Struct({ threadId: ThreadId, }); +const ThreadAutoSettleCommand = Schema.Struct({ + type: Schema.Literal("thread.auto-settle"), + commandId: CommandId, + threadId: ThreadId, + snapshotSequence: NonNegativeInt, +}); + const ThreadUnsettleCommand = Schema.Struct({ type: Schema.Literal("thread.unsettle"), commandId: CommandId, @@ -784,6 +847,7 @@ const ThreadMetaUpdateCommand = Schema.Struct({ branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), expectedBranch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), + linkedPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)), }).check( Schema.makeFilter( (input) => @@ -1050,6 +1114,7 @@ const ThreadTitleRegenerationCompleteCommand = Schema.Struct({ }); const InternalOrchestrationCommand = Schema.Union([ + ThreadAutoSettleCommand, ThreadSessionSetCommand, ThreadMessageAssistantDeltaCommand, ThreadMessageAssistantCompleteCommand, @@ -1227,6 +1292,7 @@ export const ThreadMetaUpdatedPayload = Schema.Struct({ modelSelection: Schema.optional(ModelSelection), branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), + linkedPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)), updatedAt: IsoDateTime, }); diff --git a/packages/contracts/src/providerRuntime.ts b/packages/contracts/src/providerRuntime.ts index a734c797b17d..31ffc6bfc0b6 100644 --- a/packages/contracts/src/providerRuntime.ts +++ b/packages/contracts/src/providerRuntime.ts @@ -324,6 +324,7 @@ export const ThreadTokenUsageSnapshot = Schema.Struct({ toolUses: Schema.optional(NonNegativeInt), durationMs: Schema.optional(NonNegativeInt), compactsAutomatically: Schema.optional(Schema.Boolean), + autoCompactThreshold: Schema.optional(PositiveInt), }); export type ThreadTokenUsageSnapshot = typeof ThreadTokenUsageSnapshot.Type; @@ -1196,23 +1197,5 @@ export type ProviderRuntimeEventV2 = typeof ProviderRuntimeEventV2.Type; export const ProviderRuntimeEvent = ProviderRuntimeEventV2; export type ProviderRuntimeEvent = ProviderRuntimeEventV2; -// Compatibility aliases for call sites still importing legacy names. -const ProviderRuntimeMessageDeltaEvent = ProviderRuntimeContentDeltaEvent; -export type ProviderRuntimeMessageDeltaEvent = ProviderRuntimeContentDeltaEvent; -const ProviderRuntimeMessageCompletedEvent = ProviderRuntimeItemCompletedEvent; -export type ProviderRuntimeMessageCompletedEvent = ProviderRuntimeItemCompletedEvent; -const ProviderRuntimeToolStartedEvent = ProviderRuntimeItemStartedEvent; -export type ProviderRuntimeToolStartedEvent = ProviderRuntimeItemStartedEvent; -const ProviderRuntimeToolCompletedEvent = ProviderRuntimeItemCompletedEvent; -export type ProviderRuntimeToolCompletedEvent = ProviderRuntimeItemCompletedEvent; -const ProviderRuntimeApprovalRequestedEvent = ProviderRuntimeRequestOpenedEvent; -export type ProviderRuntimeApprovalRequestedEvent = ProviderRuntimeRequestOpenedEvent; -const ProviderRuntimeApprovalResolvedEvent = ProviderRuntimeRequestResolvedEvent; -export type ProviderRuntimeApprovalResolvedEvent = ProviderRuntimeRequestResolvedEvent; - -// Legacy helper aliases retained for adapters/tests. -const ProviderRuntimeToolKind = Schema.Literals(["command", "file-read", "file-change", "other"]); -export type ProviderRuntimeToolKind = typeof ProviderRuntimeToolKind.Type; - export const ProviderRuntimeTurnStatus = RuntimeTurnState; export type ProviderRuntimeTurnStatus = RuntimeTurnState; diff --git a/packages/contracts/src/relay.ts b/packages/contracts/src/relay.ts index 52f7d7d43550..37221262ebad 100644 --- a/packages/contracts/src/relay.ts +++ b/packages/contracts/src/relay.ts @@ -8,7 +8,12 @@ import * as HttpApiSchema from "effect/unstable/httpapi/HttpApiSchema"; import * as HttpApiSecurity from "effect/unstable/httpapi/HttpApiSecurity"; import * as OpenApi from "effect/unstable/httpapi/OpenApi"; -import { EnvironmentId, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { + DpopFailureReason, + EnvironmentId, + ThreadId, + TrimmedNonEmptyString, +} from "./baseSchemas.ts"; import { ExecutionEnvironmentDescriptor } from "./environment.ts"; export const RelayAgentAwarenessPlatform = Schema.Literal("ios"); @@ -322,6 +327,9 @@ export const RelayAuthInvalidReason = Schema.Literals([ ]); export type RelayAuthInvalidReason = typeof RelayAuthInvalidReason.Type; +export const RelayDpopFailureReason = DpopFailureReason; +export type RelayDpopFailureReason = typeof RelayDpopFailureReason.Type; + export const RelayInternalErrorReason = Schema.Literals([ "database_unavailable", "persistence_failed", @@ -335,6 +343,8 @@ export class RelayAuthInvalidError extends Schema.TaggedErrorClass { + it("is accepted by a server whose schema predates the field", () => { + const oldServerPayload = Schema.Struct({}); + const decoded = Schema.decodeUnknownExit(oldServerPayload)({ environmentThemes: true }); + expect(Exit.isSuccess(decoded)).toBe(true); + }); + + it("is carried by a server that declares it", () => { + const decoded = Schema.decodeUnknownSync(WsSubscribeServerConfigRpc.payloadSchema)({ + environmentThemes: true, + }); + expect(decoded).toEqual({ environmentThemes: true }); + }); + + it("stays optional, so a client that never sends it still subscribes", () => { + const decoded = Schema.decodeUnknownSync(WsSubscribeServerConfigRpc.payloadSchema)({}); + expect(decoded).toEqual({}); + }); +}); diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 14363cfedff9..7cd674485f94 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -983,7 +983,16 @@ export const WsSubscribeTerminalMetadataRpc = Rpc.make(WS_METHODS.subscribeTermi }); export const WsSubscribeServerConfigRpc = Rpc.make(WS_METHODS.subscribeServerConfig, { - payload: Schema.Struct({}), + payload: Schema.Struct({ + /** + * Whether this client understands `environmentThemesUpdated` events. + * Already-shipped clients decode the stream against the old event union + * and would die on an unknown member, so the server emits the theme + * stream only to subscribers that ask for it. Absent on old clients; + * dropped by old servers. + */ + environmentThemes: Schema.optional(Schema.Boolean), + }), success: ServerConfigStreamEvent, error: Schema.Union([KeybindingsConfigError, ServerSettingsError, EnvironmentAuthorizationError]), stream: true, diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 9791a4f62185..1205f309c178 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -17,7 +17,7 @@ import { KeybindingWhen, ResolvedKeybindingsConfig, } from "./keybindings.ts"; -import { EditorId, RemoteOpenTarget } from "./editor.ts"; +import { EditorId, FileManagerRevealKind, RemoteOpenTarget } from "./editor.ts"; import { ModelCapabilities } from "./model.ts"; import { ProviderDriverKind, ProviderInstanceId } from "./providerInstance.ts"; import { ServerSettings } from "./settings.ts"; @@ -66,6 +66,8 @@ export const ServerProviderModel = Schema.Struct({ name: TrimmedNonEmptyString, shortName: Schema.optional(TrimmedNonEmptyString), subProvider: Schema.optional(TrimmedNonEmptyString), + aliases: Schema.optional(Schema.Array(TrimmedNonEmptyString)), + badge: Schema.optional(Schema.Literal("new")), isCustom: Schema.Boolean, isDefault: Schema.optional(Schema.Boolean), isLegacy: Schema.optional(Schema.Boolean), @@ -93,6 +95,18 @@ export const ServerProviderSkill = Schema.Struct({ enabled: Schema.Boolean, displayName: Schema.optional(TrimmedNonEmptyString), shortDescription: Schema.optional(TrimmedNonEmptyString), + /** + * The skill is hidden from the agent's own skill tool, so only the user can + * start it — Claude Code's `disable-model-invocation`. Composers must offer + * it as a slash command; naming it in prose does nothing. + */ + userInvocationOnly: Schema.optional(Schema.Boolean), + /** + * The mirror of {@link ServerProviderSkill.userInvocationOnly}: Claude Code's + * `user-invocable: false` keeps the skill out of its own slash commands, so + * only the agent can start it. Composers must not offer it under `/`. + */ + userInvocable: Schema.optional(Schema.Boolean), }); export type ServerProviderSkill = typeof ServerProviderSkill.Type; @@ -417,6 +431,93 @@ export const ServerSignalProcessResult = Schema.Struct({ }); export type ServerSignalProcessResult = typeof ServerSignalProcessResult.Type; +/** + * A palette the environment's machine publishes for T3 Code to follow, read + * from a theme file next to the rest of the environment's state. Two seed + * colors rather than a full palette: clients derive the remaining roles with + * the same generator the guided theme editor uses, so a desktop theme carries + * over as a coherent T3 Code palette instead of a foreign one. + */ +export const EnvironmentThemeColor = Schema.String.check( + Schema.isPattern(/^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/), +); +export type EnvironmentThemeColor = typeof EnvironmentThemeColor.Type; + +/** + * Matches the client-side theme id rule, so a published id is selectable. + * The appearance keywords are excluded outright: a published `dark.json` + * would otherwise capture every client whose stored preference is the stock + * `"dark"`, retinting people who never chose it. + */ +export const EnvironmentThemeId = Schema.String.check( + Schema.isPattern(/^(?!(?:system|light|dark)$)[a-z0-9](?:[a-z0-9-]{0,47})$/), +); +export type EnvironmentThemeId = typeof EnvironmentThemeId.Type; + +/** + * Role colors as published. Values are any CSS color the client's theme + * parser accepts (exported theme files use oklch), canonicalized client-side; + * roles a build does not know are dropped there, so a machine may publish + * roles a newer client added without breaking an older one. Keys must still + * be role-shaped and values color-sized, so the record stays open to future + * vocabulary without being an arbitrary-payload channel. + */ +const EnvironmentThemeColors = Schema.Record( + Schema.String.check(Schema.isPattern(/^[a-zA-Z][a-zA-Z0-9]{0,63}$/)), + TrimmedNonEmptyString.check(Schema.isMaxLength(64)), +); + +const environmentThemeFields = { + /** + * Standard exported theme files (the Download button's output) carry + * `version: 1`; the seeded short form a desktop generates has no version. + */ + version: Schema.optional(Schema.Literal(1)), + /** Shown on the theme card, e.g. the desktop theme's own name. */ + name: TrimmedNonEmptyString.check(Schema.isMaxLength(48)), + appearance: Schema.Literals(["light", "dark"]), + /** + * Seed colors. When present, clients derive the full palette from them with + * the guided theme editor's generator and layer `colors` on top; when + * absent, `colors` is the palette, as in an exported theme file. + */ + canvas: Schema.optional(EnvironmentThemeColor), + accent: Schema.optional(EnvironmentThemeColor), + colors: Schema.optional(EnvironmentThemeColors), + /** The other appearance's palette, as exported theme files carry it. */ + variants: Schema.optional( + Schema.Struct({ + light: Schema.optional(EnvironmentThemeColors), + dark: Schema.optional(EnvironmentThemeColors), + }), + ), +}; + +/** One published theme file. The id is the filename, not part of the content, + * so a file cannot claim another file's identity; an embedded `id` is ignored. */ +export const EnvironmentThemeFile = Schema.Struct(environmentThemeFields); +export type EnvironmentThemeFile = typeof EnvironmentThemeFile.Type; + +export const EnvironmentTheme = Schema.Struct({ + /** The publishing filename without its extension, stable across recolors. */ + id: EnvironmentThemeId, + ...environmentThemeFields, +}); +export type EnvironmentTheme = typeof EnvironmentTheme.Type; + +/** + * Whether a theme file carries anything to render. A file with neither seeds + * nor colors would show as the stock palette wearing a name, which reads as a + * bug rather than a theme — the CLI and the server watcher both reject it, + * through this one predicate so they cannot drift. + */ +export function environmentThemeFileHasColors(file: EnvironmentThemeFile): boolean { + return ( + (file.canvas !== undefined && file.accent !== undefined) || + (file.colors !== undefined && Object.keys(file.colors).length > 0) + ); +} + export const ServerConfig = Schema.Struct({ environment: ExecutionEnvironmentDescriptor, auth: ServerAuthDescriptor, @@ -438,6 +539,11 @@ export const ServerConfig = Schema.Struct({ settings: ServerSettings, /** Whether shell subscriptions can emit an opt-in catch-up completion marker. */ shellResumeCompletionMarker: Schema.optionalKey(Schema.Boolean), + /** Whether shell.openInEditor honors `LaunchEditorInput.reveal` for the + file-manager editor. */ + shellRevealInFileManager: Schema.optionalKey(Schema.Boolean), + /** File-manager wording clients should use for reveal actions. */ + shellRevealInFileManagerKind: Schema.optionalKey(FileManagerRevealKind), /** Whether thread subscriptions can emit an opt-in catch-up completion marker. */ threadResumeCompletionMarker: Schema.optionalKey(Schema.Boolean), /** @@ -446,6 +552,14 @@ export const ServerConfig = Schema.Struct({ * fields to servers that don't advertise this. */ threadSnapshotPagination: Schema.optionalKey(Schema.Boolean), + /** + * Palettes published by this environment's machine. Never sent in a config + * snapshot: the theme stream emits the current set before any change, so a + * snapshot carrying it too would hand every subscriber the same array twice + * per connect. Clients populate this by projecting `environmentThemesUpdated`, + * and it stays absent for subscribers that did not opt in. + */ + environmentThemes: Schema.optional(Schema.Array(EnvironmentTheme)), }); export type ServerConfig = typeof ServerConfig.Type; @@ -530,11 +644,27 @@ export const ServerConfigStreamSettingsUpdatedEvent = Schema.Struct({ export type ServerConfigStreamSettingsUpdatedEvent = typeof ServerConfigStreamSettingsUpdatedEvent.Type; +export const ServerConfigEnvironmentThemesUpdatedPayload = Schema.Struct({ + /** The full published set; empty once the machine publishes none. */ + themes: Schema.Array(EnvironmentTheme), +}); +export type ServerConfigEnvironmentThemesUpdatedPayload = + typeof ServerConfigEnvironmentThemesUpdatedPayload.Type; + +export const ServerConfigStreamEnvironmentThemesUpdatedEvent = Schema.Struct({ + version: Schema.Literal(1), + type: Schema.Literal("environmentThemesUpdated"), + payload: ServerConfigEnvironmentThemesUpdatedPayload, +}); +export type ServerConfigStreamEnvironmentThemesUpdatedEvent = + typeof ServerConfigStreamEnvironmentThemesUpdatedEvent.Type; + export const ServerConfigStreamEvent = Schema.Union([ ServerConfigStreamSnapshotEvent, ServerConfigStreamKeybindingsUpdatedEvent, ServerConfigStreamProviderStatusesEvent, ServerConfigStreamSettingsUpdatedEvent, + ServerConfigStreamEnvironmentThemesUpdatedEvent, ]); export type ServerConfigStreamEvent = typeof ServerConfigStreamEvent.Type; diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 55023bcc48e7..6c71a0e42970 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -5,6 +5,7 @@ import { ProviderDriverKind, ProviderInstanceId } from "./providerInstance.ts"; import { ClientSettingsSchema, ClientSettingsPatch, + ClaudeSettings, DEFAULT_SERVER_SETTINGS, defaultEnabledForDriver, resolveProviderInstanceEnabled, @@ -14,9 +15,40 @@ import { const decodeClientSettings = Schema.decodeUnknownSync(ClientSettingsSchema); const decodeClientSettingsPatch = Schema.decodeUnknownSync(ClientSettingsPatch); +const encodeClientSettings = Schema.encodeSync(ClientSettingsSchema); const decodeServerSettings = Schema.decodeUnknownSync(ServerSettings); const decodeServerSettingsPatch = Schema.decodeUnknownSync(ServerSettingsPatch); const encodeServerSettings = Schema.encodeSync(ServerSettings); +const decodeClaudeSettings = Schema.decodeUnknownSync(ClaudeSettings); + +describe("ClaudeSettings auto-compaction", () => { + it("uses Claude's default threshold when no override is configured", () => { + expect(decodeClaudeSettings({}).autoCompactWindow).toBe(""); + }); + + it.each(["100000", "300000", "1000000"])( + "accepts a supported auto-compaction threshold: %s", + (value) => { + expect(decodeClaudeSettings({ autoCompactWindow: value }).autoCompactWindow).toBe(value); + }, + ); + + it.each(["99999", "1000001", "300k", "invalid"])( + "rejects an unsupported auto-compaction threshold: %s", + (value) => { + expect(() => decodeClaudeSettings({ autoCompactWindow: value })).toThrow(); + }, + ); + + it("rejects an unsupported threshold at the settings patch boundary", () => { + expect(() => + decodeServerSettingsPatch({ providers: { claudeAgent: { autoCompactWindow: "300k" } } }), + ).toThrow(); + expect( + decodeServerSettingsPatch({ providers: { claudeAgent: { autoCompactWindow: "300000" } } }), + ).toBeDefined(); + }); +}); describe("ClientSettings word wrap", () => { it("defaults word wrap on", () => { @@ -35,6 +67,51 @@ describe("ClientSettings word wrap", () => { }); }); +describe("ClientSettings quit confirmation", () => { + it("defaults to hold", () => { + expect(decodeClientSettings({}).confirmQuit).toBe("hold"); + }); + + it.each(["direct", "hold", "double-click"] as const)("accepts the %s mode", (mode) => { + expect(decodeClientSettings({ confirmQuit: mode }).confirmQuit).toBe(mode); + expect(decodeClientSettingsPatch({ confirmQuit: mode }).confirmQuit).toBe(mode); + }); + + it.each([ + [true, "hold"], + [false, "direct"], + ] as const)("migrates the legacy %s value to %s", (legacyValue, mode) => { + const settings = decodeClientSettings({ confirmQuit: legacyValue }); + + expect(settings.confirmQuit).toBe(mode); + expect(encodeClientSettings(settings).confirmQuit).toBe(mode); + }); + + it("rejects legacy booleans at the patch boundary", () => { + expect(() => decodeClientSettingsPatch({ confirmQuit: true })).toThrow(); + }); +}); + +describe("ClientSettings browser recording frame rate", () => { + it("defaults to 30 fps", () => { + expect(decodeClientSettings({}).browserRecordingFrameRate).toBe(30); + }); + + it.each([30, 60])("accepts a supported frame rate: %s", (frameRate) => { + expect( + decodeClientSettings({ browserRecordingFrameRate: frameRate }).browserRecordingFrameRate, + ).toBe(frameRate); + expect( + decodeClientSettingsPatch({ browserRecordingFrameRate: frameRate }).browserRecordingFrameRate, + ).toBe(frameRate); + }); + + it.each([24, 59, 120])("rejects an unsupported frame rate: %s", (frameRate) => { + expect(() => decodeClientSettings({ browserRecordingFrameRate: frameRate })).toThrow(); + expect(() => decodeClientSettingsPatch({ browserRecordingFrameRate: frameRate })).toThrow(); + }); +}); + describe("ClientSettings glass opacity", () => { it("defaults to a readable translucent surface", () => { expect(decodeClientSettings({}).glassOpacity).toBe(80); @@ -86,11 +163,8 @@ describe("ClientSettings environment identification", () => { }); describe("ClientSettings sidebar", () => { - it("defaults to the current sidebar with automatic merge and inactivity settling", () => { - const settings = decodeClientSettings({}); - expect(settings.legacySidebarEnabled).toBe(false); - expect(settings.sidebarAutoSettleAfterDays).toBe(3); - expect(settings.sidebarAutoSettleOnMerge).toBe(true); + it("defaults to the current sidebar", () => { + expect(decodeClientSettings({}).legacySidebarEnabled).toBe(false); }); it("drops the retired sidebar v2 beta keys, resetting everyone to the default", () => { @@ -110,24 +184,38 @@ describe("ClientSettings sidebar", () => { ); }); - it("allows auto-settle by inactivity to be disabled", () => { - expect( - decodeClientSettings({ sidebarAutoSettleAfterDays: null }).sidebarAutoSettleAfterDays, - ).toBeNull(); + it("keeps unpin confirmation opt-in and patchable", () => { + expect(decodeClientSettings({}).confirmThreadUnpin).toBe(false); + expect(decodeClientSettingsPatch({ confirmThreadUnpin: true }).confirmThreadUnpin).toBe(true); + expect(() => decodeClientSettingsPatch({ confirmThreadUnpin: "yes" })).toThrow(); }); +}); - it("allows auto-settle on merge to be disabled", () => { - expect(decodeClientSettings({ sidebarAutoSettleOnMerge: false }).sidebarAutoSettleOnMerge).toBe( - false, - ); +describe("ServerSettings thread settlement", () => { + it("defaults merge settlement on and inactivity settlement to three days", () => { + const settings = decodeServerSettings({}); + expect(settings.sidebarAutoSettleAfterDays).toBe(3); + expect(settings.sidebarAutoSettleOnMerge).toBe(true); + }); + + it("allows both automatic rules to be disabled", () => { expect( - decodeClientSettingsPatch({ sidebarAutoSettleOnMerge: false }).sidebarAutoSettleOnMerge, - ).toBe(false); + decodeServerSettings({ + sidebarAutoSettleAfterDays: null, + sidebarAutoSettleOnMerge: false, + }), + ).toMatchObject({ sidebarAutoSettleAfterDays: null, sidebarAutoSettleOnMerge: false }); + expect( + decodeServerSettingsPatch({ + sidebarAutoSettleAfterDays: null, + sidebarAutoSettleOnMerge: false, + }), + ).toMatchObject({ sidebarAutoSettleAfterDays: null, sidebarAutoSettleOnMerge: false }); }); it.each([-1, 0, 91])("rejects an auto-settle threshold outside 1..90: %s", (value) => { - expect(() => decodeClientSettings({ sidebarAutoSettleAfterDays: value })).toThrow(); - expect(() => decodeClientSettingsPatch({ sidebarAutoSettleAfterDays: value })).toThrow(); + expect(() => decodeServerSettings({ sidebarAutoSettleAfterDays: value })).toThrow(); + expect(() => decodeServerSettingsPatch({ sidebarAutoSettleAfterDays: value })).toThrow(); }); }); @@ -200,19 +288,33 @@ describe("provider enabled defaults", () => { const decoded = decodeServerSettings({}); expect(decoded.providers.codex.enabled).toBe(true); expect(decoded.providers.claudeAgent.enabled).toBe(true); - expect(decoded.providers.cursor.enabled).toBe(true); + expect(decoded.providers.cursor.enabled).toBe(false); expect(decoded.providers.grok.enabled).toBe(false); expect(decoded.providers.opencode.enabled).toBe(false); }); it("derives per-driver defaults from the settings schemas", () => { expect(defaultEnabledForDriver(ProviderDriverKind.make("codex"))).toBe(true); - expect(defaultEnabledForDriver(ProviderDriverKind.make("cursor"))).toBe(true); + expect(defaultEnabledForDriver(ProviderDriverKind.make("cursor"))).toBe(false); expect(defaultEnabledForDriver(ProviderDriverKind.make("grok"))).toBe(false); // Unknown fork drivers stay enabled; their own build decides otherwise. expect(defaultEnabledForDriver(ProviderDriverKind.make("ollama"))).toBe(true); }); + it("keeps Cursor enabled when an existing user explicitly opted in", () => { + const cursor = ProviderDriverKind.make("cursor"); + const cursorId = ProviderInstanceId.make("cursor"); + const decoded = decodeServerSettings({ + providers: { cursor: { enabled: true } }, + providerInstances: { + [cursorId]: { driver: cursor, enabled: true, config: {} }, + }, + }); + + expect(decoded.providers.cursor.enabled).toBe(true); + expect(resolveProviderInstanceEnabled(decoded.providerInstances[cursorId]!)).toBe(true); + }); + it("resolves instance enabled state with explicit false winning", () => { const grok = ProviderDriverKind.make("grok"); const codex = ProviderDriverKind.make("codex"); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 80e03b8c879e..6ec4df5da1ff 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -124,6 +124,22 @@ export const EnvironmentIdentificationMode = Schema.Literals(["artwork", "pill", export type EnvironmentIdentificationMode = typeof EnvironmentIdentificationMode.Type; export const DEFAULT_ENVIRONMENT_IDENTIFICATION_MODE: EnvironmentIdentificationMode = "artwork"; +export const QuitConfirmationMode = Schema.Literals(["direct", "hold", "double-click"]); +export type QuitConfirmationMode = typeof QuitConfirmationMode.Type; +export const DEFAULT_QUIT_CONFIRMATION_MODE: QuitConfirmationMode = "hold"; + +const LegacyConfirmQuit = Schema.Boolean.pipe( + Schema.decodeTo( + QuitConfirmationMode, + SchemaTransformation.transform({ + decode: (confirmQuit): QuitConfirmationMode => (confirmQuit ? "hold" : "direct"), + encode: (mode) => mode === "hold", + }), + ), +); + +const QuitConfirmationModeSetting = Schema.Union([QuitConfirmationMode, LegacyConfirmQuit]); + /** * A user-chosen font family (a single name or a comma-separated list). Empty * means "use the app default"; clients compose their own fallback stacks. @@ -131,14 +147,32 @@ export const DEFAULT_ENVIRONMENT_IDENTIFICATION_MODE: EnvironmentIdentificationM export const FontFamilyPreference = Schema.String.check(Schema.isMaxLength(200)); export type FontFamilyPreference = typeof FontFamilyPreference.Type; +/** + * The environment's theme, set with `t3 theme set `. Each client applies + * it once per value — live when connected, on its next connect otherwise — so + * setting it switches every client, while a theme a user picks in Settings + * afterwards sticks until the next set. Empty means "no environment theme", + * which is also how it is cleared. + */ +export const DefaultThemePreference = Schema.String.check(Schema.isMaxLength(64)); +// Deliberately absent from ServerSettingsPatch: `t3 theme set` checks that an +// id is syntactically valid and actually resolvable, and a generic RPC patch +// would let a client write a theme no client can resolve, bypassing both. +export type DefaultThemePreference = typeof DefaultThemePreference.Type; + /** * Defaults for the in-app preview browser, applied whenever a tab is opened * without an explicit viewport/zoom/appearance — by the user opening a browser - * tab, or by an agent calling `preview_open` with no size. Client-local - * because the Chromium guest they configure is desktop-local. + * tab, or by an agent calling `preview_open` with no size. Recording quality is + * client-local for the same reason: the Chromium guest being captured belongs + * to the desktop app. */ export const DEFAULT_BROWSER_VIEWPORT: PreviewViewportSetting = FILL_PREVIEW_VIEWPORT; export const DEFAULT_BROWSER_AUTO_SHOW_FLOATING_PREVIEW = true; +export const BROWSER_RECORDING_FRAME_RATES = [30, 60] as const; +export const BrowserRecordingFrameRate = Schema.Literals(BROWSER_RECORDING_FRAME_RATES); +export type BrowserRecordingFrameRate = typeof BrowserRecordingFrameRate.Type; +export const DEFAULT_BROWSER_RECORDING_FRAME_RATE: BrowserRecordingFrameRate = 30; export const ClientSettingsSchema = Schema.Struct({ appearanceContrast: AppearanceContrast.pipe( @@ -153,6 +187,9 @@ export const ClientSettingsSchema = Schema.Struct({ browserDefaultAppearance: PreviewAppearancePreference.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_PREVIEW_APPEARANCE)), ), + browserRecordingFrameRate: BrowserRecordingFrameRate.pipe( + Schema.withDecodingDefault(Effect.succeed(DEFAULT_BROWSER_RECORDING_FRAME_RATE)), + ), /** * Whether an agent opening a preview pops the floating mini player into * view. Only applies when the agent didn't ask either way — an explicit @@ -162,11 +199,14 @@ export const ClientSettingsSchema = Schema.Struct({ browserAutoShowFloatingPreview: Schema.Boolean.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_BROWSER_AUTO_SHOW_FLOATING_PREVIEW)), ), - // Desktop-only: require holding the quit shortcut (Cmd/Ctrl+Q) before the - // app quits; a quick tap only shows a hint. Browser clients ignore it. - confirmQuit: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), + // Desktop-only. Boolean values from older settings files decode to their + // equivalent mode and encode back as the canonical string value. + confirmQuit: QuitConfirmationModeSetting.pipe( + Schema.withDecodingDefault(Effect.succeed(DEFAULT_QUIT_CONFIRMATION_MODE)), + ), confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), confirmThreadDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), + confirmThreadUnpin: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), dismissedProviderUpdateNotificationKeys: Schema.Array(TrimmedNonEmptyString).pipe( Schema.withDecodingDefault(Effect.succeed([])), ), @@ -231,10 +271,6 @@ export const ClientSettingsSchema = Schema.Struct({ // old keys, so everyone, including prior beta opt-outs, resets to the new // default sidebar. legacySidebarEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), - sidebarAutoSettleAfterDays: Schema.NullOr(SidebarAutoSettleAfterDays).pipe( - Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_AUTO_SETTLE_AFTER_DAYS)), - ), - sidebarAutoSettleOnMerge: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), sidebarProjectGroupingMode: SidebarProjectGroupingMode.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE)), ), @@ -262,10 +298,6 @@ export const DEFAULT_CLIENT_SETTINGS: ClientSettings = Schema.decodeSync(ClientS // ── Server Settings (server-authoritative) ──────────────────── -// Moved to environment.ts so orchestration contracts can use it without an -// import cycle; re-exported here for compatibility with deep imports. -export { ThreadEnvMode } from "./environment.ts"; - const makeBinaryPathSetting = (fallback: string) => TrimmedString.pipe( Schema.decodeTo( @@ -373,6 +405,11 @@ export const CodexSettings = makeProviderSettingsSchema( ); export type CodexSettings = typeof CodexSettings.Type; +// Empty, or an integer from 100,000 to 1,000,000. Shared by the full +// Claude settings schema and its patch so an out-of-range value fails at +// the update that introduced it. +const CLAUDE_AUTO_COMPACT_WINDOW_PATTERN = /^(?:|[1-9]\d{5}|1000000)$/; + export const ClaudeSettings = makeProviderSettingsSchema( { enabled: Schema.Boolean.pipe( @@ -410,18 +447,32 @@ export const ClaudeSettings = makeProviderSettingsSchema( }, }), ), + autoCompactWindow: TrimmedString.check( + Schema.isPattern(CLAUDE_AUTO_COMPACT_WINDOW_PATTERN), + ).pipe( + Schema.withDecodingDefault(Effect.succeed("")), + Schema.annotateKey({ + title: "Auto-compact after", + description: + "Compact after 100,000 to 1,000,000 tokens. Leave empty to use Claude's default.", + providerSettingsForm: { + placeholder: "e.g. 300000", + clearWhenEmpty: "omit", + }, + }), + ), }, { - order: ["binaryPath", "homePath", "launchArgs"], + order: ["binaryPath", "homePath", "autoCompactWindow", "launchArgs"], }, ); export type ClaudeSettings = typeof ClaudeSettings.Type; export const CursorSettings = makeProviderSettingsSchema( { - // Enabled by default alongside Codex and Claude Agent. + // Off by default like Grok and OpenCode. Users opt in from Settings. enabled: Schema.Boolean.pipe( - Schema.withDecodingDefault(Effect.succeed(true)), + Schema.withDecodingDefault(Effect.succeed(false)), Schema.annotateKey({ providerSettingsForm: { hidden: true } }), ), binaryPath: makeBinaryPathSetting("cursor-agent").pipe( @@ -617,6 +668,10 @@ export const ServerSettings = Schema.Struct({ * between a desktop window and a phone attached to the same server. */ enableAgentBrowserAccess: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), + sidebarAutoSettleAfterDays: Schema.NullOr(SidebarAutoSettleAfterDays).pipe( + Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_AUTO_SETTLE_AFTER_DAYS)), + ), + sidebarAutoSettleOnMerge: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), backgroundActivity: BackgroundActivitySettings, // Legacy flat fields retained for old settings files and old clients. New // consumers should resolve `backgroundActivity` instead. @@ -633,6 +688,17 @@ export const ServerSettings = Schema.Struct({ backgroundActivityProfile: BackgroundActivityProfile.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_BACKGROUND_ACTIVITY_PROFILE)), ), + defaultTheme: DefaultThemePreference.pipe(Schema.withDecodingDefault(Effect.succeed(""))), + /** + * When the environment's theme was last set, so clients can tell a re-set + * of the same value from one they already applied: `t3 theme set` must act + * even when it names the theme it named before. Empty on environments + * provisioned by builds that predate it, where clients fall back to + * applying once per value. + */ + defaultThemeSetAt: Schema.String.check(Schema.isMaxLength(64)).pipe( + Schema.withDecodingDefault(Effect.succeed("")), + ), defaultThreadEnvMode: ThreadEnvMode.pipe( Schema.withDecodingDefault(Effect.succeed("local" as const satisfies ThreadEnvMode)), ), @@ -736,6 +802,7 @@ export const ServerSettingsOperation = Schema.Literals([ "normalize", "check-exists", "read-file", + "read-provider-history", "read-secret", "remove-secret", "remove-stale-secret", @@ -797,6 +864,11 @@ const ClaudeSettingsPatch = Schema.Struct({ homePath: Schema.optionalKey(TrimmedString), customModels: Schema.optionalKey(Schema.Array(Schema.String)), launchArgs: Schema.optionalKey(TrimmedString), + // Validated at the patch boundary so a typo fails the one update with a + // schema error instead of a generic whole-settings failure. + autoCompactWindow: Schema.optionalKey( + TrimmedString.check(Schema.isPattern(CLAUDE_AUTO_COMPACT_WINDOW_PATTERN)), + ), }); const CursorSettingsPatch = Schema.Struct({ @@ -825,6 +897,8 @@ export const ServerSettingsPatch = Schema.Struct({ enableLegacyTokenStreaming: Schema.optionalKey(Schema.Boolean), enableProviderUpdateChecks: Schema.optionalKey(Schema.Boolean), enableAgentBrowserAccess: Schema.optionalKey(Schema.Boolean), + sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)), + sidebarAutoSettleOnMerge: Schema.optionalKey(Schema.Boolean), backgroundActivity: Schema.optionalKey( Schema.Struct({ schemaVersion: Schema.optionalKey(Schema.Literal(1)), @@ -876,10 +950,12 @@ export const ClientSettingsPatch = Schema.Struct({ browserDefaultViewport: Schema.optionalKey(PreviewViewportSetting), browserDefaultZoomFactor: Schema.optionalKey(PreviewZoomFactor), browserDefaultAppearance: Schema.optionalKey(PreviewAppearancePreference), + browserRecordingFrameRate: Schema.optionalKey(BrowserRecordingFrameRate), browserAutoShowFloatingPreview: Schema.optionalKey(Schema.Boolean), - confirmQuit: Schema.optionalKey(Schema.Boolean), + confirmQuit: Schema.optionalKey(QuitConfirmationMode), confirmThreadArchive: Schema.optionalKey(Schema.Boolean), confirmThreadDelete: Schema.optionalKey(Schema.Boolean), + confirmThreadUnpin: Schema.optionalKey(Schema.Boolean), diffIgnoreWhitespace: Schema.optionalKey(Schema.Boolean), environmentIdentificationMode: Schema.optionalKey(EnvironmentIdentificationMode), glassOpacity: Schema.optionalKey(GlassOpacity), @@ -916,8 +992,6 @@ export const ClientSettingsPatch = Schema.Struct({ planModeEnabled: Schema.optionalKey(Schema.Boolean), showSkillsInSlashMenu: Schema.optionalKey(Schema.Boolean), legacySidebarEnabled: Schema.optionalKey(Schema.Boolean), - sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)), - sidebarAutoSettleOnMerge: Schema.optionalKey(Schema.Boolean), sidebarProjectGroupingMode: Schema.optionalKey(SidebarProjectGroupingMode), sidebarProjectGroupingOverrides: Schema.optionalKey( Schema.Record(TrimmedNonEmptyString, SidebarProjectGroupingMode), diff --git a/packages/contracts/src/usage.ts b/packages/contracts/src/usage.ts index cde888a6153e..8c099ddb33aa 100644 --- a/packages/contracts/src/usage.ts +++ b/packages/contracts/src/usage.ts @@ -2,10 +2,10 @@ * Usage reporting contract. * * Each environment scans the provider CLIs' own on-disk session transcripts - * (`~/.claude/projects/**\/*.jsonl`, `~/.codex/sessions/**\/*.jsonl`) rather than - * relying on T3 Code's own orchestration projections, so usage stays complete - * even for turns that were never driven through T3 Code. This mirrors the - * approach `ccusage` takes. + * (`~/.claude/projects/**\/*.jsonl`, `~/.codex/sessions/**\/*.jsonl`, + * `~/.grok/sessions/**\/updates.jsonl`) rather than relying on T3 Code's own + * orchestration projections, so usage stays complete even for turns that were + * never driven through T3 Code. This mirrors the approach `ccusage` takes. * * Environments return pre-aggregated `(day, hourStart?, provider, model)` * buckets. Raw transcript records never cross the wire. @@ -21,9 +21,18 @@ import { NonNegativeInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; * client renders partial coverage when an environment reports an older version * rather than failing the whole page. */ -export const USAGE_CONTRACT_VERSION = 4 as const; +export const USAGE_CONTRACT_VERSION = 5 as const; -export const UsageProviderKind = Schema.Literals(["claude", "codex"]); +/** + * Oldest {@link UsageSummary} version a current client will still merge. + * + * v5 only adds `grok` to {@link UsageProviderKind}; v4 Claude/Codex buckets + * remain valid, so mixed-version environments keep those totals instead of + * treating every older server as stale. + */ +export const USAGE_MERGE_COMPATIBLE_SINCE = 4 as const; + +export const UsageProviderKind = Schema.Literals(["claude", "codex", "grok"]); export type UsageProviderKind = typeof UsageProviderKind.Type; /** diff --git a/packages/effect-acp/package.json b/packages/effect-acp/package.json index 4455dd460e76..7f1aa3b1078b 100644 --- a/packages/effect-acp/package.json +++ b/packages/effect-acp/package.json @@ -15,18 +15,10 @@ "types": "./src/schema.ts", "import": "./src/schema.ts" }, - "./rpc": { - "types": "./src/rpc.ts", - "import": "./src/rpc.ts" - }, "./protocol": { "types": "./src/protocol.ts", "import": "./src/protocol.ts" }, - "./terminal": { - "types": "./src/terminal.ts", - "import": "./src/terminal.ts" - }, "./errors": { "types": "./src/errors.ts", "import": "./src/errors.ts" diff --git a/packages/effect-acp/src/protocol.test.ts b/packages/effect-acp/src/protocol.test.ts index a66cc75225da..7ca86b063e23 100644 --- a/packages/effect-acp/src/protocol.test.ts +++ b/packages/effect-acp/src/protocol.test.ts @@ -135,6 +135,41 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => { }), ); + it.effect("keeps only recent raw notifications after their callbacks run", () => + Effect.gen(function* () { + const { stdio, input } = yield* makeInMemoryStdio(); + const handled = yield* Deferred.make(); + let handledCount = 0; + const transport = yield* AcpProtocol.makeAcpPatchedProtocol({ + stdio, + serverRequestMethods: new Set(), + onNotification: () => + Effect.sync(() => ++handledCount).pipe( + Effect.flatMap((count) => + count === 64 ? Deferred.succeed(handled, undefined).pipe(Effect.asVoid) : Effect.void, + ), + ), + }); + + const messages = Array.from({ length: 64 }, (_, index) => + encodeUnknownJsonString({ + jsonrpc: "2.0", + method: "x/performance", + params: { index }, + }), + ); + yield* Queue.offer(input, encoder.encode(`${messages.join("\n")}\n`)); + yield* Deferred.await(handled); + + const retained = yield* transport.incoming.pipe(Stream.take(32), Stream.runCollect); + + assert.equal(handledCount, 64); + assert.equal(retained.length, 32); + assert.deepEqual(retained[0]?.params, { index: 32 }); + assert.deepEqual(retained[31]?.params, { index: 63 }); + }), + ); + it.effect("keeps invalid core notification values only in the schema cause", () => Effect.gen(function* () { const secret = "acp-core-notification-secret-sentinel"; diff --git a/packages/effect-acp/src/protocol.ts b/packages/effect-acp/src/protocol.ts index d61641fbb7b5..44a48bd1ef76 100644 --- a/packages/effect-acp/src/protocol.ts +++ b/packages/effect-acp/src/protocol.ts @@ -76,6 +76,7 @@ const decodeElicitationComplete = Schema.decodeUnknownEffect( AcpSchema.ElicitationCompleteNotification, ); const parserFactory = RpcSerialization.ndJsonRpc(); +const MAX_BUFFERED_RAW_NOTIFICATIONS = 32; export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(function* ( options: AcpPatchedProtocolOptions, @@ -83,7 +84,9 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi const parser = parserFactory.makeUnsafe(); const serverQueue = yield* Queue.unbounded(); const clientQueue = yield* Queue.unbounded(); - const notificationQueue = yield* Queue.unbounded(); + const notificationQueue = yield* Queue.sliding( + MAX_BUFFERED_RAW_NOTIFICATIONS, + ); const disconnects = yield* Queue.unbounded(); const outgoing = yield* Queue.unbounded>(); const nextRequestId = yield* Ref.make(1); @@ -408,11 +411,14 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi yield* options.stdio.stdin.pipe( Stream.runForEach((data) => - logProtocol({ - direction: "incoming", - stage: "raw", - payload: typeof data === "string" ? data : new TextDecoder().decode(data), - }).pipe( + (options.logIncoming + ? logProtocol({ + direction: "incoming", + stage: "raw", + payload: typeof data === "string" ? data : new TextDecoder().decode(data), + }) + : Effect.void + ).pipe( Effect.flatMap(() => Effect.try({ try: () => diff --git a/packages/effect-acp/test/examples/cursor-acp-client.example.ts b/packages/effect-acp/test/examples/cursor-acp-client.example.ts deleted file mode 100644 index b7a146cf5c21..000000000000 --- a/packages/effect-acp/test/examples/cursor-acp-client.example.ts +++ /dev/null @@ -1,81 +0,0 @@ -import * as Effect from "effect/Effect"; -import * as Console from "effect/Console"; -import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; - -import * as NodeServices from "@effect/platform-node/NodeServices"; -import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; - -import * as AcpClient from "../../src/client.ts"; - -const program = Effect.gen(function* () { - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const command = ChildProcess.make("cursor-agent", ["acp"], { - cwd: process.cwd(), - shell: false, - }); - const handle = yield* spawner.spawn(command); - const acpLayer = AcpClient.layerChildProcess(handle, { - logIncoming: true, - logOutgoing: true, - }); - - yield* Effect.gen(function* () { - const acp = yield* AcpClient.AcpClient; - - yield* acp.handleRequestPermission(() => - Effect.succeed({ - outcome: { - outcome: "selected", - optionId: "allow", - }, - }), - ); - // yield* acp.handleSessionUpdate((notification) => - // Console.log("session/update", JSON.stringify(notification)), - // ); - - const initialized = yield* acp.agent.initialize({ - protocolVersion: 1, - clientCapabilities: { - fs: { readTextFile: false, writeTextFile: false }, - terminal: false, - _meta: { - parameterizedModelPicker: true, - }, - }, - clientInfo: { - name: "effect-acp-example", - version: "0.0.0", - }, - }); - yield* Console.log("initialized", initialized); - - const session = yield* acp.agent.createSession({ - cwd: process.cwd(), - mcpServers: [], - }); - - const config = yield* acp.agent.setSessionConfigOption({ - sessionId: session.sessionId, - configId: "model", - value: "claude-opus-4-6", - }); - - yield* Console.log("config", config); - - const result = yield* acp.agent.prompt({ - sessionId: session.sessionId, - prompt: [ - { - type: "text", - text: "Illustrate your ability to create todo lists and then execute all of them. Do not write the list to disk, illustrate your built in ability!", - }, - ], - }); - - yield* Console.log("prompt result", result); - yield* acp.agent.cancel({ sessionId: session.sessionId }); - }).pipe(Effect.provide(acpLayer)); -}); - -program.pipe(Effect.scoped, Effect.provide(NodeServices.layer), NodeRuntime.runMain); diff --git a/packages/effect-codex-app-server/package.json b/packages/effect-codex-app-server/package.json index a067976c616a..d8894a9980fb 100644 --- a/packages/effect-codex-app-server/package.json +++ b/packages/effect-codex-app-server/package.json @@ -15,10 +15,6 @@ "types": "./src/rpc.ts", "import": "./src/rpc.ts" }, - "./protocol": { - "types": "./src/protocol.ts", - "import": "./src/protocol.ts" - }, "./errors": { "types": "./src/errors.ts", "import": "./src/errors.ts" diff --git a/packages/effect-codex-app-server/scripts/generate.ts b/packages/effect-codex-app-server/scripts/generate.ts index 9f23a144524d..3622cb8f5158 100644 --- a/packages/effect-codex-app-server/scripts/generate.ts +++ b/packages/effect-codex-app-server/scripts/generate.ts @@ -145,6 +145,55 @@ const ManualSchemas: Record = { }, }; +// Codex 0.150 added these multi-agent values before our next full protocol +// refresh. Keep every generated response namespace compatible with them. +const Codex0150DefinitionSchemas: Record = { + CollabAgentTool: { + type: "string", + enum: [ + "spawnAgent", + "sendInput", + "resumeAgent", + "wait", + "closeAgent", + "sendMessage", + "followupTask", + "interruptAgent", + "listAgents", + ], + }, + CollabAgentToolCallStatus: { + type: "string", + enum: ["inProgress", "completed", "failed", "interrupted"], + }, + PlanType: { + type: "string", + enum: [ + "free", + "go", + "plus", + "pro", + "prolite", + "team", + "self_serve_business_prolite", + "self_serve_business_usage_based", + "business", + "ent26", + "enterprise_cbp_automation", + "enterprise_cbp_usage_based", + "enterprise", + "edu", + "edu_plus", + "edu_pro", + "unknown", + ], + }, + SubAgentActivityKind: { + type: "string", + enum: ["started", "interacted", "interrupted", "completed"], + }, +}; + const getGeneratedPaths = Effect.fn("getGeneratedPaths")(function* () { const path = yield* Path.Path; const generatedDir = path.join(import.meta.dirname, "..", "src", "_generated"); @@ -556,10 +605,12 @@ const generateFiles = Effect.fn("generateFiles")(function* () { ); for (const [definitionName, definitionSchema] of Object.entries(parsed.definitions ?? {})) { + const compatibleDefinitionSchema = + Codex0150DefinitionSchemas[definitionName] ?? definitionSchema; aggregateSchemas[localDefinitionNames.get(definitionName)!] = stripNullDefaults( normalizeNullableTypes( rewriteExternalRefs( - definitionSchema, + compatibleDefinitionSchema, localDefinitionNames, file.namespace, exportNameByQualifiedName, diff --git a/packages/effect-codex-app-server/src/_generated/schema.gen.ts b/packages/effect-codex-app-server/src/_generated/schema.gen.ts index d826df60f192..34df10eb03b2 100644 --- a/packages/effect-codex-app-server/src/_generated/schema.gen.ts +++ b/packages/effect-codex-app-server/src/_generated/schema.gen.ts @@ -2411,11 +2411,16 @@ export type ServerNotification__PlanType = | "pro" | "prolite" | "team" + | "self_serve_business_prolite" | "self_serve_business_usage_based" | "business" + | "ent26" + | "enterprise_cbp_automation" | "enterprise_cbp_usage_based" | "enterprise" | "edu" + | "edu_plus" + | "edu_pro" | "unknown"; export const ServerNotification__PlanType = Schema.Literals([ "free", @@ -2424,11 +2429,16 @@ export const ServerNotification__PlanType = Schema.Literals([ "pro", "prolite", "team", + "self_serve_business_prolite", "self_serve_business_usage_based", "business", + "ent26", + "enterprise_cbp_automation", "enterprise_cbp_usage_based", "enterprise", "edu", + "edu_plus", + "edu_pro", "unknown", ]); @@ -2616,11 +2626,16 @@ export const ServerNotification__SpendControlLimitSnapshot = Schema.Struct({ used: Schema.String, }); -export type ServerNotification__SubAgentActivityKind = "started" | "interacted" | "interrupted"; +export type ServerNotification__SubAgentActivityKind = + | "started" + | "interacted" + | "interrupted" + | "completed"; export const ServerNotification__SubAgentActivityKind = Schema.Literals([ "started", "interacted", "interrupted", + "completed", ]); export type ServerNotification__TerminalInteractionNotification = { @@ -3243,11 +3258,16 @@ export type V2AccountRateLimitsUpdatedNotification__PlanType = | "pro" | "prolite" | "team" + | "self_serve_business_prolite" | "self_serve_business_usage_based" | "business" + | "ent26" + | "enterprise_cbp_automation" | "enterprise_cbp_usage_based" | "enterprise" | "edu" + | "edu_plus" + | "edu_pro" | "unknown"; export const V2AccountRateLimitsUpdatedNotification__PlanType = Schema.Literals([ "free", @@ -3256,11 +3276,16 @@ export const V2AccountRateLimitsUpdatedNotification__PlanType = Schema.Literals( "pro", "prolite", "team", + "self_serve_business_prolite", "self_serve_business_usage_based", "business", + "ent26", + "enterprise_cbp_automation", "enterprise_cbp_usage_based", "enterprise", "edu", + "edu_plus", + "edu_pro", "unknown", ]); @@ -3331,11 +3356,16 @@ export type V2AccountUpdatedNotification__PlanType = | "pro" | "prolite" | "team" + | "self_serve_business_prolite" | "self_serve_business_usage_based" | "business" + | "ent26" + | "enterprise_cbp_automation" | "enterprise_cbp_usage_based" | "enterprise" | "edu" + | "edu_plus" + | "edu_pro" | "unknown"; export const V2AccountUpdatedNotification__PlanType = Schema.Literals([ "free", @@ -3344,11 +3374,16 @@ export const V2AccountUpdatedNotification__PlanType = Schema.Literals([ "pro", "prolite", "team", + "self_serve_business_prolite", "self_serve_business_usage_based", "business", + "ent26", + "enterprise_cbp_automation", "enterprise_cbp_usage_based", "enterprise", "edu", + "edu_plus", + "edu_pro", "unknown", ]); @@ -4135,11 +4170,16 @@ export type V2GetAccountRateLimitsResponse__PlanType = | "pro" | "prolite" | "team" + | "self_serve_business_prolite" | "self_serve_business_usage_based" | "business" + | "ent26" + | "enterprise_cbp_automation" | "enterprise_cbp_usage_based" | "enterprise" | "edu" + | "edu_plus" + | "edu_pro" | "unknown"; export const V2GetAccountRateLimitsResponse__PlanType = Schema.Literals([ "free", @@ -4148,11 +4188,16 @@ export const V2GetAccountRateLimitsResponse__PlanType = Schema.Literals([ "pro", "prolite", "team", + "self_serve_business_prolite", "self_serve_business_usage_based", "business", + "ent26", + "enterprise_cbp_automation", "enterprise_cbp_usage_based", "enterprise", "edu", + "edu_plus", + "edu_pro", "unknown", ]); @@ -4223,11 +4268,16 @@ export type V2GetAccountResponse__PlanType = | "pro" | "prolite" | "team" + | "self_serve_business_prolite" | "self_serve_business_usage_based" | "business" + | "ent26" + | "enterprise_cbp_automation" | "enterprise_cbp_usage_based" | "enterprise" | "edu" + | "edu_plus" + | "edu_pro" | "unknown"; export const V2GetAccountResponse__PlanType = Schema.Literals([ "free", @@ -4236,11 +4286,16 @@ export const V2GetAccountResponse__PlanType = Schema.Literals([ "pro", "prolite", "team", + "self_serve_business_prolite", "self_serve_business_usage_based", "business", + "ent26", + "enterprise_cbp_automation", "enterprise_cbp_usage_based", "enterprise", "edu", + "edu_plus", + "edu_pro", "unknown", ]); @@ -4710,11 +4765,13 @@ export const V2ItemCompletedNotification__ReasoningEffort = Schema.String.annota export type V2ItemCompletedNotification__SubAgentActivityKind = | "started" | "interacted" - | "interrupted"; + | "interrupted" + | "completed"; export const V2ItemCompletedNotification__SubAgentActivityKind = Schema.Literals([ "started", "interacted", "interrupted", + "completed", ]); export type V2ItemCompletedNotification__TextElement = { @@ -5115,11 +5172,13 @@ export const V2ItemStartedNotification__ReasoningEffort = Schema.String.annotate export type V2ItemStartedNotification__SubAgentActivityKind = | "started" | "interacted" - | "interrupted"; + | "interrupted" + | "completed"; export const V2ItemStartedNotification__SubAgentActivityKind = Schema.Literals([ "started", "interacted", "interrupted", + "completed", ]); export type V2ItemStartedNotification__TextElement = { @@ -6284,11 +6343,16 @@ export const V2ReviewStartResponse__ReasoningEffort = Schema.String.annotate({ description: "A non-empty reasoning effort value advertised by the model.", }).check(Schema.isMinLength(1)); -export type V2ReviewStartResponse__SubAgentActivityKind = "started" | "interacted" | "interrupted"; +export type V2ReviewStartResponse__SubAgentActivityKind = + | "started" + | "interacted" + | "interrupted" + | "completed"; export const V2ReviewStartResponse__SubAgentActivityKind = Schema.Literals([ "started", "interacted", "interrupted", + "completed", ]); export type V2ReviewStartResponse__TextElement = { @@ -6720,11 +6784,16 @@ export const V2ThreadForkResponse__ReasoningEffort = Schema.String.annotate({ description: "A non-empty reasoning effort value advertised by the model.", }).check(Schema.isMinLength(1)); -export type V2ThreadForkResponse__SubAgentActivityKind = "started" | "interacted" | "interrupted"; +export type V2ThreadForkResponse__SubAgentActivityKind = + | "started" + | "interacted" + | "interrupted" + | "completed"; export const V2ThreadForkResponse__SubAgentActivityKind = Schema.Literals([ "started", "interacted", "interrupted", + "completed", ]); export type V2ThreadForkResponse__TextElement = { @@ -7119,11 +7188,16 @@ export const V2ThreadListResponse__ReasoningEffort = Schema.String.annotate({ description: "A non-empty reasoning effort value advertised by the model.", }).check(Schema.isMinLength(1)); -export type V2ThreadListResponse__SubAgentActivityKind = "started" | "interacted" | "interrupted"; +export type V2ThreadListResponse__SubAgentActivityKind = + | "started" + | "interacted" + | "interrupted" + | "completed"; export const V2ThreadListResponse__SubAgentActivityKind = Schema.Literals([ "started", "interacted", "interrupted", + "completed", ]); export type V2ThreadListResponse__TextElement = { @@ -7463,11 +7537,13 @@ export const V2ThreadMetadataUpdateResponse__ReasoningEffort = Schema.String.ann export type V2ThreadMetadataUpdateResponse__SubAgentActivityKind = | "started" | "interacted" - | "interrupted"; + | "interrupted" + | "completed"; export const V2ThreadMetadataUpdateResponse__SubAgentActivityKind = Schema.Literals([ "started", "interacted", "interrupted", + "completed", ]); export type V2ThreadMetadataUpdateResponse__TextElement = { @@ -7760,11 +7836,16 @@ export const V2ThreadReadResponse__ReasoningEffort = Schema.String.annotate({ description: "A non-empty reasoning effort value advertised by the model.", }).check(Schema.isMinLength(1)); -export type V2ThreadReadResponse__SubAgentActivityKind = "started" | "interacted" | "interrupted"; +export type V2ThreadReadResponse__SubAgentActivityKind = + | "started" + | "interacted" + | "interrupted" + | "completed"; export const V2ThreadReadResponse__SubAgentActivityKind = Schema.Literals([ "started", "interacted", "interrupted", + "completed", ]); export type V2ThreadReadResponse__TextElement = { @@ -8342,11 +8423,16 @@ export const V2ThreadResumeResponse__ReasoningEffort = Schema.String.annotate({ description: "A non-empty reasoning effort value advertised by the model.", }).check(Schema.isMinLength(1)); -export type V2ThreadResumeResponse__SubAgentActivityKind = "started" | "interacted" | "interrupted"; +export type V2ThreadResumeResponse__SubAgentActivityKind = + | "started" + | "interacted" + | "interrupted" + | "completed"; export const V2ThreadResumeResponse__SubAgentActivityKind = Schema.Literals([ "started", "interacted", "interrupted", + "completed", ]); export type V2ThreadResumeResponse__TextElement = { @@ -8643,11 +8729,13 @@ export const V2ThreadRollbackResponse__ReasoningEffort = Schema.String.annotate( export type V2ThreadRollbackResponse__SubAgentActivityKind = | "started" | "interacted" - | "interrupted"; + | "interrupted" + | "completed"; export const V2ThreadRollbackResponse__SubAgentActivityKind = Schema.Literals([ "started", "interacted", "interrupted", + "completed", ]); export type V2ThreadRollbackResponse__TextElement = { @@ -9051,11 +9139,13 @@ export const V2ThreadStartedNotification__ReasoningEffort = Schema.String.annota export type V2ThreadStartedNotification__SubAgentActivityKind = | "started" | "interacted" - | "interrupted"; + | "interrupted" + | "completed"; export const V2ThreadStartedNotification__SubAgentActivityKind = Schema.Literals([ "started", "interacted", "interrupted", + "completed", ]); export type V2ThreadStartedNotification__TextElement = { @@ -9458,11 +9548,16 @@ export const V2ThreadStartResponse__ReasoningEffort = Schema.String.annotate({ description: "A non-empty reasoning effort value advertised by the model.", }).check(Schema.isMinLength(1)); -export type V2ThreadStartResponse__SubAgentActivityKind = "started" | "interacted" | "interrupted"; +export type V2ThreadStartResponse__SubAgentActivityKind = + | "started" + | "interacted" + | "interrupted" + | "completed"; export const V2ThreadStartResponse__SubAgentActivityKind = Schema.Literals([ "started", "interacted", "interrupted", + "completed", ]); export type V2ThreadStartResponse__TextElement = { @@ -9791,11 +9886,13 @@ export const V2ThreadUnarchiveResponse__ReasoningEffort = Schema.String.annotate export type V2ThreadUnarchiveResponse__SubAgentActivityKind = | "started" | "interacted" - | "interrupted"; + | "interrupted" + | "completed"; export const V2ThreadUnarchiveResponse__SubAgentActivityKind = Schema.Literals([ "started", "interacted", "interrupted", + "completed", ]); export type V2ThreadUnarchiveResponse__TextElement = { @@ -10095,11 +10192,13 @@ export const V2TurnCompletedNotification__ReasoningEffort = Schema.String.annota export type V2TurnCompletedNotification__SubAgentActivityKind = | "started" | "interacted" - | "interrupted"; + | "interrupted" + | "completed"; export const V2TurnCompletedNotification__SubAgentActivityKind = Schema.Literals([ "started", "interacted", "interrupted", + "completed", ]); export type V2TurnCompletedNotification__TextElement = { @@ -10385,11 +10484,13 @@ export const V2TurnStartedNotification__ReasoningEffort = Schema.String.annotate export type V2TurnStartedNotification__SubAgentActivityKind = | "started" | "interacted" - | "interrupted"; + | "interrupted" + | "completed"; export const V2TurnStartedNotification__SubAgentActivityKind = Schema.Literals([ "started", "interacted", "interrupted", + "completed", ]); export type V2TurnStartedNotification__TextElement = { @@ -10761,11 +10862,16 @@ export const V2TurnStartResponse__ReasoningEffort = Schema.String.annotate({ description: "A non-empty reasoning effort value advertised by the model.", }).check(Schema.isMinLength(1)); -export type V2TurnStartResponse__SubAgentActivityKind = "started" | "interacted" | "interrupted"; +export type V2TurnStartResponse__SubAgentActivityKind = + | "started" + | "interacted" + | "interrupted" + | "completed"; export const V2TurnStartResponse__SubAgentActivityKind = Schema.Literals([ "started", "interacted", "interrupted", + "completed", ]); export type V2TurnStartResponse__TextElement = { @@ -20357,8 +20463,17 @@ export type ServerNotification__ThreadItem = readonly reasoningEffort?: ServerNotification__ReasoningEffort | null; readonly receiverThreadIds: ReadonlyArray; readonly senderThreadId: string; - readonly status: "inProgress" | "completed" | "failed"; - readonly tool: "spawnAgent" | "sendInput" | "resumeAgent" | "wait" | "closeAgent"; + readonly status: "inProgress" | "completed" | "failed" | "interrupted"; + readonly tool: + | "spawnAgent" + | "sendInput" + | "resumeAgent" + | "wait" + | "closeAgent" + | "sendMessage" + | "followupTask" + | "interruptAgent" + | "listAgents"; readonly type: "collabAgentToolCall"; } | { @@ -20580,7 +20695,7 @@ export const ServerNotification__ThreadItem = Schema.Union( senderThreadId: Schema.String.annotate({ description: "Thread ID of the agent issuing the collab request.", }), - status: Schema.Literals(["inProgress", "completed", "failed"]).annotate({ + status: Schema.Literals(["inProgress", "completed", "failed", "interrupted"]).annotate({ description: "Current status of the collab tool call.", }), tool: Schema.Literals([ @@ -20589,6 +20704,10 @@ export const ServerNotification__ThreadItem = Schema.Union( "resumeAgent", "wait", "closeAgent", + "sendMessage", + "followupTask", + "interruptAgent", + "listAgents", ]).annotate({ description: "Name of the collab tool that was invoked." }), type: Schema.Literal("collabAgentToolCall").annotate({ title: "CollabAgentToolCallThreadItemType", @@ -21440,8 +21559,17 @@ export type V2ItemCompletedNotification__ThreadItem = readonly reasoningEffort?: V2ItemCompletedNotification__ReasoningEffort | null; readonly receiverThreadIds: ReadonlyArray; readonly senderThreadId: string; - readonly status: "inProgress" | "completed" | "failed"; - readonly tool: "spawnAgent" | "sendInput" | "resumeAgent" | "wait" | "closeAgent"; + readonly status: "inProgress" | "completed" | "failed" | "interrupted"; + readonly tool: + | "spawnAgent" + | "sendInput" + | "resumeAgent" + | "wait" + | "closeAgent" + | "sendMessage" + | "followupTask" + | "interruptAgent" + | "listAgents"; readonly type: "collabAgentToolCall"; } | { @@ -21668,7 +21796,7 @@ export const V2ItemCompletedNotification__ThreadItem = Schema.Union( senderThreadId: Schema.String.annotate({ description: "Thread ID of the agent issuing the collab request.", }), - status: Schema.Literals(["inProgress", "completed", "failed"]).annotate({ + status: Schema.Literals(["inProgress", "completed", "failed", "interrupted"]).annotate({ description: "Current status of the collab tool call.", }), tool: Schema.Literals([ @@ -21677,6 +21805,10 @@ export const V2ItemCompletedNotification__ThreadItem = Schema.Union( "resumeAgent", "wait", "closeAgent", + "sendMessage", + "followupTask", + "interruptAgent", + "listAgents", ]).annotate({ description: "Name of the collab tool that was invoked." }), type: Schema.Literal("collabAgentToolCall").annotate({ title: "CollabAgentToolCallThreadItemType", @@ -21891,8 +22023,17 @@ export type V2ItemStartedNotification__ThreadItem = readonly reasoningEffort?: V2ItemStartedNotification__ReasoningEffort | null; readonly receiverThreadIds: ReadonlyArray; readonly senderThreadId: string; - readonly status: "inProgress" | "completed" | "failed"; - readonly tool: "spawnAgent" | "sendInput" | "resumeAgent" | "wait" | "closeAgent"; + readonly status: "inProgress" | "completed" | "failed" | "interrupted"; + readonly tool: + | "spawnAgent" + | "sendInput" + | "resumeAgent" + | "wait" + | "closeAgent" + | "sendMessage" + | "followupTask" + | "interruptAgent" + | "listAgents"; readonly type: "collabAgentToolCall"; } | { @@ -22119,7 +22260,7 @@ export const V2ItemStartedNotification__ThreadItem = Schema.Union( senderThreadId: Schema.String.annotate({ description: "Thread ID of the agent issuing the collab request.", }), - status: Schema.Literals(["inProgress", "completed", "failed"]).annotate({ + status: Schema.Literals(["inProgress", "completed", "failed", "interrupted"]).annotate({ description: "Current status of the collab tool call.", }), tool: Schema.Literals([ @@ -22128,6 +22269,10 @@ export const V2ItemStartedNotification__ThreadItem = Schema.Union( "resumeAgent", "wait", "closeAgent", + "sendMessage", + "followupTask", + "interruptAgent", + "listAgents", ]).annotate({ description: "Name of the collab tool that was invoked." }), type: Schema.Literal("collabAgentToolCall").annotate({ title: "CollabAgentToolCallThreadItemType", @@ -22514,8 +22659,17 @@ export type V2ReviewStartResponse__ThreadItem = readonly reasoningEffort?: V2ReviewStartResponse__ReasoningEffort | null; readonly receiverThreadIds: ReadonlyArray; readonly senderThreadId: string; - readonly status: "inProgress" | "completed" | "failed"; - readonly tool: "spawnAgent" | "sendInput" | "resumeAgent" | "wait" | "closeAgent"; + readonly status: "inProgress" | "completed" | "failed" | "interrupted"; + readonly tool: + | "spawnAgent" + | "sendInput" + | "resumeAgent" + | "wait" + | "closeAgent" + | "sendMessage" + | "followupTask" + | "interruptAgent" + | "listAgents"; readonly type: "collabAgentToolCall"; } | { @@ -22739,7 +22893,7 @@ export const V2ReviewStartResponse__ThreadItem = Schema.Union( senderThreadId: Schema.String.annotate({ description: "Thread ID of the agent issuing the collab request.", }), - status: Schema.Literals(["inProgress", "completed", "failed"]).annotate({ + status: Schema.Literals(["inProgress", "completed", "failed", "interrupted"]).annotate({ description: "Current status of the collab tool call.", }), tool: Schema.Literals([ @@ -22748,6 +22902,10 @@ export const V2ReviewStartResponse__ThreadItem = Schema.Union( "resumeAgent", "wait", "closeAgent", + "sendMessage", + "followupTask", + "interruptAgent", + "listAgents", ]).annotate({ description: "Name of the collab tool that was invoked." }), type: Schema.Literal("collabAgentToolCall").annotate({ title: "CollabAgentToolCallThreadItemType", @@ -22950,8 +23108,17 @@ export type V2ThreadForkResponse__ThreadItem = readonly reasoningEffort?: V2ThreadForkResponse__ReasoningEffort | null; readonly receiverThreadIds: ReadonlyArray; readonly senderThreadId: string; - readonly status: "inProgress" | "completed" | "failed"; - readonly tool: "spawnAgent" | "sendInput" | "resumeAgent" | "wait" | "closeAgent"; + readonly status: "inProgress" | "completed" | "failed" | "interrupted"; + readonly tool: + | "spawnAgent" + | "sendInput" + | "resumeAgent" + | "wait" + | "closeAgent" + | "sendMessage" + | "followupTask" + | "interruptAgent" + | "listAgents"; readonly type: "collabAgentToolCall"; } | { @@ -23175,7 +23342,7 @@ export const V2ThreadForkResponse__ThreadItem = Schema.Union( senderThreadId: Schema.String.annotate({ description: "Thread ID of the agent issuing the collab request.", }), - status: Schema.Literals(["inProgress", "completed", "failed"]).annotate({ + status: Schema.Literals(["inProgress", "completed", "failed", "interrupted"]).annotate({ description: "Current status of the collab tool call.", }), tool: Schema.Literals([ @@ -23184,6 +23351,10 @@ export const V2ThreadForkResponse__ThreadItem = Schema.Union( "resumeAgent", "wait", "closeAgent", + "sendMessage", + "followupTask", + "interruptAgent", + "listAgents", ]).annotate({ description: "Name of the collab tool that was invoked." }), type: Schema.Literal("collabAgentToolCall").annotate({ title: "CollabAgentToolCallThreadItemType", @@ -23355,8 +23526,17 @@ export type V2ThreadListResponse__ThreadItem = readonly reasoningEffort?: V2ThreadListResponse__ReasoningEffort | null; readonly receiverThreadIds: ReadonlyArray; readonly senderThreadId: string; - readonly status: "inProgress" | "completed" | "failed"; - readonly tool: "spawnAgent" | "sendInput" | "resumeAgent" | "wait" | "closeAgent"; + readonly status: "inProgress" | "completed" | "failed" | "interrupted"; + readonly tool: + | "spawnAgent" + | "sendInput" + | "resumeAgent" + | "wait" + | "closeAgent" + | "sendMessage" + | "followupTask" + | "interruptAgent" + | "listAgents"; readonly type: "collabAgentToolCall"; } | { @@ -23580,7 +23760,7 @@ export const V2ThreadListResponse__ThreadItem = Schema.Union( senderThreadId: Schema.String.annotate({ description: "Thread ID of the agent issuing the collab request.", }), - status: Schema.Literals(["inProgress", "completed", "failed"]).annotate({ + status: Schema.Literals(["inProgress", "completed", "failed", "interrupted"]).annotate({ description: "Current status of the collab tool call.", }), tool: Schema.Literals([ @@ -23589,6 +23769,10 @@ export const V2ThreadListResponse__ThreadItem = Schema.Union( "resumeAgent", "wait", "closeAgent", + "sendMessage", + "followupTask", + "interruptAgent", + "listAgents", ]).annotate({ description: "Name of the collab tool that was invoked." }), type: Schema.Literal("collabAgentToolCall").annotate({ title: "CollabAgentToolCallThreadItemType", @@ -23762,8 +23946,17 @@ export type V2ThreadMetadataUpdateResponse__ThreadItem = readonly reasoningEffort?: V2ThreadMetadataUpdateResponse__ReasoningEffort | null; readonly receiverThreadIds: ReadonlyArray; readonly senderThreadId: string; - readonly status: "inProgress" | "completed" | "failed"; - readonly tool: "spawnAgent" | "sendInput" | "resumeAgent" | "wait" | "closeAgent"; + readonly status: "inProgress" | "completed" | "failed" | "interrupted"; + readonly tool: + | "spawnAgent" + | "sendInput" + | "resumeAgent" + | "wait" + | "closeAgent" + | "sendMessage" + | "followupTask" + | "interruptAgent" + | "listAgents"; readonly type: "collabAgentToolCall"; } | { @@ -23990,7 +24183,7 @@ export const V2ThreadMetadataUpdateResponse__ThreadItem = Schema.Union( senderThreadId: Schema.String.annotate({ description: "Thread ID of the agent issuing the collab request.", }), - status: Schema.Literals(["inProgress", "completed", "failed"]).annotate({ + status: Schema.Literals(["inProgress", "completed", "failed", "interrupted"]).annotate({ description: "Current status of the collab tool call.", }), tool: Schema.Literals([ @@ -23999,6 +24192,10 @@ export const V2ThreadMetadataUpdateResponse__ThreadItem = Schema.Union( "resumeAgent", "wait", "closeAgent", + "sendMessage", + "followupTask", + "interruptAgent", + "listAgents", ]).annotate({ description: "Name of the collab tool that was invoked." }), type: Schema.Literal("collabAgentToolCall").annotate({ title: "CollabAgentToolCallThreadItemType", @@ -24170,8 +24367,17 @@ export type V2ThreadReadResponse__ThreadItem = readonly reasoningEffort?: V2ThreadReadResponse__ReasoningEffort | null; readonly receiverThreadIds: ReadonlyArray; readonly senderThreadId: string; - readonly status: "inProgress" | "completed" | "failed"; - readonly tool: "spawnAgent" | "sendInput" | "resumeAgent" | "wait" | "closeAgent"; + readonly status: "inProgress" | "completed" | "failed" | "interrupted"; + readonly tool: + | "spawnAgent" + | "sendInput" + | "resumeAgent" + | "wait" + | "closeAgent" + | "sendMessage" + | "followupTask" + | "interruptAgent" + | "listAgents"; readonly type: "collabAgentToolCall"; } | { @@ -24395,7 +24601,7 @@ export const V2ThreadReadResponse__ThreadItem = Schema.Union( senderThreadId: Schema.String.annotate({ description: "Thread ID of the agent issuing the collab request.", }), - status: Schema.Literals(["inProgress", "completed", "failed"]).annotate({ + status: Schema.Literals(["inProgress", "completed", "failed", "interrupted"]).annotate({ description: "Current status of the collab tool call.", }), tool: Schema.Literals([ @@ -24404,6 +24610,10 @@ export const V2ThreadReadResponse__ThreadItem = Schema.Union( "resumeAgent", "wait", "closeAgent", + "sendMessage", + "followupTask", + "interruptAgent", + "listAgents", ]).annotate({ description: "Name of the collab tool that was invoked." }), type: Schema.Literal("collabAgentToolCall").annotate({ title: "CollabAgentToolCallThreadItemType", @@ -24583,8 +24793,17 @@ export type V2ThreadResumeResponse__ThreadItem = readonly reasoningEffort?: V2ThreadResumeResponse__ReasoningEffort | null; readonly receiverThreadIds: ReadonlyArray; readonly senderThreadId: string; - readonly status: "inProgress" | "completed" | "failed"; - readonly tool: "spawnAgent" | "sendInput" | "resumeAgent" | "wait" | "closeAgent"; + readonly status: "inProgress" | "completed" | "failed" | "interrupted"; + readonly tool: + | "spawnAgent" + | "sendInput" + | "resumeAgent" + | "wait" + | "closeAgent" + | "sendMessage" + | "followupTask" + | "interruptAgent" + | "listAgents"; readonly type: "collabAgentToolCall"; } | { @@ -24808,7 +25027,7 @@ export const V2ThreadResumeResponse__ThreadItem = Schema.Union( senderThreadId: Schema.String.annotate({ description: "Thread ID of the agent issuing the collab request.", }), - status: Schema.Literals(["inProgress", "completed", "failed"]).annotate({ + status: Schema.Literals(["inProgress", "completed", "failed", "interrupted"]).annotate({ description: "Current status of the collab tool call.", }), tool: Schema.Literals([ @@ -24817,6 +25036,10 @@ export const V2ThreadResumeResponse__ThreadItem = Schema.Union( "resumeAgent", "wait", "closeAgent", + "sendMessage", + "followupTask", + "interruptAgent", + "listAgents", ]).annotate({ description: "Name of the collab tool that was invoked." }), type: Schema.Literal("collabAgentToolCall").annotate({ title: "CollabAgentToolCallThreadItemType", @@ -24988,8 +25211,17 @@ export type V2ThreadRollbackResponse__ThreadItem = readonly reasoningEffort?: V2ThreadRollbackResponse__ReasoningEffort | null; readonly receiverThreadIds: ReadonlyArray; readonly senderThreadId: string; - readonly status: "inProgress" | "completed" | "failed"; - readonly tool: "spawnAgent" | "sendInput" | "resumeAgent" | "wait" | "closeAgent"; + readonly status: "inProgress" | "completed" | "failed" | "interrupted"; + readonly tool: + | "spawnAgent" + | "sendInput" + | "resumeAgent" + | "wait" + | "closeAgent" + | "sendMessage" + | "followupTask" + | "interruptAgent" + | "listAgents"; readonly type: "collabAgentToolCall"; } | { @@ -25216,7 +25448,7 @@ export const V2ThreadRollbackResponse__ThreadItem = Schema.Union( senderThreadId: Schema.String.annotate({ description: "Thread ID of the agent issuing the collab request.", }), - status: Schema.Literals(["inProgress", "completed", "failed"]).annotate({ + status: Schema.Literals(["inProgress", "completed", "failed", "interrupted"]).annotate({ description: "Current status of the collab tool call.", }), tool: Schema.Literals([ @@ -25225,6 +25457,10 @@ export const V2ThreadRollbackResponse__ThreadItem = Schema.Union( "resumeAgent", "wait", "closeAgent", + "sendMessage", + "followupTask", + "interruptAgent", + "listAgents", ]).annotate({ description: "Name of the collab tool that was invoked." }), type: Schema.Literal("collabAgentToolCall").annotate({ title: "CollabAgentToolCallThreadItemType", @@ -25407,8 +25643,17 @@ export type V2ThreadStartedNotification__ThreadItem = readonly reasoningEffort?: V2ThreadStartedNotification__ReasoningEffort | null; readonly receiverThreadIds: ReadonlyArray; readonly senderThreadId: string; - readonly status: "inProgress" | "completed" | "failed"; - readonly tool: "spawnAgent" | "sendInput" | "resumeAgent" | "wait" | "closeAgent"; + readonly status: "inProgress" | "completed" | "failed" | "interrupted"; + readonly tool: + | "spawnAgent" + | "sendInput" + | "resumeAgent" + | "wait" + | "closeAgent" + | "sendMessage" + | "followupTask" + | "interruptAgent" + | "listAgents"; readonly type: "collabAgentToolCall"; } | { @@ -25635,7 +25880,7 @@ export const V2ThreadStartedNotification__ThreadItem = Schema.Union( senderThreadId: Schema.String.annotate({ description: "Thread ID of the agent issuing the collab request.", }), - status: Schema.Literals(["inProgress", "completed", "failed"]).annotate({ + status: Schema.Literals(["inProgress", "completed", "failed", "interrupted"]).annotate({ description: "Current status of the collab tool call.", }), tool: Schema.Literals([ @@ -25644,6 +25889,10 @@ export const V2ThreadStartedNotification__ThreadItem = Schema.Union( "resumeAgent", "wait", "closeAgent", + "sendMessage", + "followupTask", + "interruptAgent", + "listAgents", ]).annotate({ description: "Name of the collab tool that was invoked." }), type: Schema.Literal("collabAgentToolCall").annotate({ title: "CollabAgentToolCallThreadItemType", @@ -25815,8 +26064,17 @@ export type V2ThreadStartResponse__ThreadItem = readonly reasoningEffort?: V2ThreadStartResponse__ReasoningEffort | null; readonly receiverThreadIds: ReadonlyArray; readonly senderThreadId: string; - readonly status: "inProgress" | "completed" | "failed"; - readonly tool: "spawnAgent" | "sendInput" | "resumeAgent" | "wait" | "closeAgent"; + readonly status: "inProgress" | "completed" | "failed" | "interrupted"; + readonly tool: + | "spawnAgent" + | "sendInput" + | "resumeAgent" + | "wait" + | "closeAgent" + | "sendMessage" + | "followupTask" + | "interruptAgent" + | "listAgents"; readonly type: "collabAgentToolCall"; } | { @@ -26040,7 +26298,7 @@ export const V2ThreadStartResponse__ThreadItem = Schema.Union( senderThreadId: Schema.String.annotate({ description: "Thread ID of the agent issuing the collab request.", }), - status: Schema.Literals(["inProgress", "completed", "failed"]).annotate({ + status: Schema.Literals(["inProgress", "completed", "failed", "interrupted"]).annotate({ description: "Current status of the collab tool call.", }), tool: Schema.Literals([ @@ -26049,6 +26307,10 @@ export const V2ThreadStartResponse__ThreadItem = Schema.Union( "resumeAgent", "wait", "closeAgent", + "sendMessage", + "followupTask", + "interruptAgent", + "listAgents", ]).annotate({ description: "Name of the collab tool that was invoked." }), type: Schema.Literal("collabAgentToolCall").annotate({ title: "CollabAgentToolCallThreadItemType", @@ -26220,8 +26482,17 @@ export type V2ThreadUnarchiveResponse__ThreadItem = readonly reasoningEffort?: V2ThreadUnarchiveResponse__ReasoningEffort | null; readonly receiverThreadIds: ReadonlyArray; readonly senderThreadId: string; - readonly status: "inProgress" | "completed" | "failed"; - readonly tool: "spawnAgent" | "sendInput" | "resumeAgent" | "wait" | "closeAgent"; + readonly status: "inProgress" | "completed" | "failed" | "interrupted"; + readonly tool: + | "spawnAgent" + | "sendInput" + | "resumeAgent" + | "wait" + | "closeAgent" + | "sendMessage" + | "followupTask" + | "interruptAgent" + | "listAgents"; readonly type: "collabAgentToolCall"; } | { @@ -26448,7 +26719,7 @@ export const V2ThreadUnarchiveResponse__ThreadItem = Schema.Union( senderThreadId: Schema.String.annotate({ description: "Thread ID of the agent issuing the collab request.", }), - status: Schema.Literals(["inProgress", "completed", "failed"]).annotate({ + status: Schema.Literals(["inProgress", "completed", "failed", "interrupted"]).annotate({ description: "Current status of the collab tool call.", }), tool: Schema.Literals([ @@ -26457,6 +26728,10 @@ export const V2ThreadUnarchiveResponse__ThreadItem = Schema.Union( "resumeAgent", "wait", "closeAgent", + "sendMessage", + "followupTask", + "interruptAgent", + "listAgents", ]).annotate({ description: "Name of the collab tool that was invoked." }), type: Schema.Literal("collabAgentToolCall").annotate({ title: "CollabAgentToolCallThreadItemType", @@ -26630,8 +26905,17 @@ export type V2TurnCompletedNotification__ThreadItem = readonly reasoningEffort?: V2TurnCompletedNotification__ReasoningEffort | null; readonly receiverThreadIds: ReadonlyArray; readonly senderThreadId: string; - readonly status: "inProgress" | "completed" | "failed"; - readonly tool: "spawnAgent" | "sendInput" | "resumeAgent" | "wait" | "closeAgent"; + readonly status: "inProgress" | "completed" | "failed" | "interrupted"; + readonly tool: + | "spawnAgent" + | "sendInput" + | "resumeAgent" + | "wait" + | "closeAgent" + | "sendMessage" + | "followupTask" + | "interruptAgent" + | "listAgents"; readonly type: "collabAgentToolCall"; } | { @@ -26858,7 +27142,7 @@ export const V2TurnCompletedNotification__ThreadItem = Schema.Union( senderThreadId: Schema.String.annotate({ description: "Thread ID of the agent issuing the collab request.", }), - status: Schema.Literals(["inProgress", "completed", "failed"]).annotate({ + status: Schema.Literals(["inProgress", "completed", "failed", "interrupted"]).annotate({ description: "Current status of the collab tool call.", }), tool: Schema.Literals([ @@ -26867,6 +27151,10 @@ export const V2TurnCompletedNotification__ThreadItem = Schema.Union( "resumeAgent", "wait", "closeAgent", + "sendMessage", + "followupTask", + "interruptAgent", + "listAgents", ]).annotate({ description: "Name of the collab tool that was invoked." }), type: Schema.Literal("collabAgentToolCall").annotate({ title: "CollabAgentToolCallThreadItemType", @@ -27038,8 +27326,17 @@ export type V2TurnStartedNotification__ThreadItem = readonly reasoningEffort?: V2TurnStartedNotification__ReasoningEffort | null; readonly receiverThreadIds: ReadonlyArray; readonly senderThreadId: string; - readonly status: "inProgress" | "completed" | "failed"; - readonly tool: "spawnAgent" | "sendInput" | "resumeAgent" | "wait" | "closeAgent"; + readonly status: "inProgress" | "completed" | "failed" | "interrupted"; + readonly tool: + | "spawnAgent" + | "sendInput" + | "resumeAgent" + | "wait" + | "closeAgent" + | "sendMessage" + | "followupTask" + | "interruptAgent" + | "listAgents"; readonly type: "collabAgentToolCall"; } | { @@ -27266,7 +27563,7 @@ export const V2TurnStartedNotification__ThreadItem = Schema.Union( senderThreadId: Schema.String.annotate({ description: "Thread ID of the agent issuing the collab request.", }), - status: Schema.Literals(["inProgress", "completed", "failed"]).annotate({ + status: Schema.Literals(["inProgress", "completed", "failed", "interrupted"]).annotate({ description: "Current status of the collab tool call.", }), tool: Schema.Literals([ @@ -27275,6 +27572,10 @@ export const V2TurnStartedNotification__ThreadItem = Schema.Union( "resumeAgent", "wait", "closeAgent", + "sendMessage", + "followupTask", + "interruptAgent", + "listAgents", ]).annotate({ description: "Name of the collab tool that was invoked." }), type: Schema.Literal("collabAgentToolCall").annotate({ title: "CollabAgentToolCallThreadItemType", @@ -27446,8 +27747,17 @@ export type V2TurnStartResponse__ThreadItem = readonly reasoningEffort?: V2TurnStartResponse__ReasoningEffort | null; readonly receiverThreadIds: ReadonlyArray; readonly senderThreadId: string; - readonly status: "inProgress" | "completed" | "failed"; - readonly tool: "spawnAgent" | "sendInput" | "resumeAgent" | "wait" | "closeAgent"; + readonly status: "inProgress" | "completed" | "failed" | "interrupted"; + readonly tool: + | "spawnAgent" + | "sendInput" + | "resumeAgent" + | "wait" + | "closeAgent" + | "sendMessage" + | "followupTask" + | "interruptAgent" + | "listAgents"; readonly type: "collabAgentToolCall"; } | { @@ -27669,7 +27979,7 @@ export const V2TurnStartResponse__ThreadItem = Schema.Union( senderThreadId: Schema.String.annotate({ description: "Thread ID of the agent issuing the collab request.", }), - status: Schema.Literals(["inProgress", "completed", "failed"]).annotate({ + status: Schema.Literals(["inProgress", "completed", "failed", "interrupted"]).annotate({ description: "Current status of the collab tool call.", }), tool: Schema.Literals([ @@ -27678,6 +27988,10 @@ export const V2TurnStartResponse__ThreadItem = Schema.Union( "resumeAgent", "wait", "closeAgent", + "sendMessage", + "followupTask", + "interruptAgent", + "listAgents", ]).annotate({ description: "Name of the collab tool that was invoked." }), type: Schema.Literal("collabAgentToolCall").annotate({ title: "CollabAgentToolCallThreadItemType", @@ -35971,20 +36285,33 @@ export type ServerNotification__CollabAgentTool = | "sendInput" | "resumeAgent" | "wait" - | "closeAgent"; + | "closeAgent" + | "sendMessage" + | "followupTask" + | "interruptAgent" + | "listAgents"; export const ServerNotification__CollabAgentTool = Schema.Literals([ "spawnAgent", "sendInput", "resumeAgent", "wait", "closeAgent", + "sendMessage", + "followupTask", + "interruptAgent", + "listAgents", ]); -export type ServerNotification__CollabAgentToolCallStatus = "inProgress" | "completed" | "failed"; +export type ServerNotification__CollabAgentToolCallStatus = + | "inProgress" + | "completed" + | "failed" + | "interrupted"; export const ServerNotification__CollabAgentToolCallStatus = Schema.Literals([ "inProgress", "completed", "failed", + "interrupted", ]); export type ServerNotification__CommandExecOutputStream = "stdout" | "stderr"; @@ -38046,23 +38373,33 @@ export type V2ItemCompletedNotification__CollabAgentTool = | "sendInput" | "resumeAgent" | "wait" - | "closeAgent"; + | "closeAgent" + | "sendMessage" + | "followupTask" + | "interruptAgent" + | "listAgents"; export const V2ItemCompletedNotification__CollabAgentTool = Schema.Literals([ "spawnAgent", "sendInput", "resumeAgent", "wait", "closeAgent", + "sendMessage", + "followupTask", + "interruptAgent", + "listAgents", ]); export type V2ItemCompletedNotification__CollabAgentToolCallStatus = | "inProgress" | "completed" - | "failed"; + | "failed" + | "interrupted"; export const V2ItemCompletedNotification__CollabAgentToolCallStatus = Schema.Literals([ "inProgress", "completed", "failed", + "interrupted", ]); export type V2ItemCompletedNotification__CommandExecutionSource = @@ -38183,23 +38520,33 @@ export type V2ItemStartedNotification__CollabAgentTool = | "sendInput" | "resumeAgent" | "wait" - | "closeAgent"; + | "closeAgent" + | "sendMessage" + | "followupTask" + | "interruptAgent" + | "listAgents"; export const V2ItemStartedNotification__CollabAgentTool = Schema.Literals([ "spawnAgent", "sendInput", "resumeAgent", "wait", "closeAgent", + "sendMessage", + "followupTask", + "interruptAgent", + "listAgents", ]); export type V2ItemStartedNotification__CollabAgentToolCallStatus = | "inProgress" | "completed" - | "failed"; + | "failed" + | "interrupted"; export const V2ItemStartedNotification__CollabAgentToolCallStatus = Schema.Literals([ "inProgress", "completed", "failed", + "interrupted", ]); export type V2ItemStartedNotification__CommandExecutionSource = @@ -39200,23 +39547,33 @@ export type V2ReviewStartResponse__CollabAgentTool = | "sendInput" | "resumeAgent" | "wait" - | "closeAgent"; + | "closeAgent" + | "sendMessage" + | "followupTask" + | "interruptAgent" + | "listAgents"; export const V2ReviewStartResponse__CollabAgentTool = Schema.Literals([ "spawnAgent", "sendInput", "resumeAgent", "wait", "closeAgent", + "sendMessage", + "followupTask", + "interruptAgent", + "listAgents", ]); export type V2ReviewStartResponse__CollabAgentToolCallStatus = | "inProgress" | "completed" - | "failed"; + | "failed" + | "interrupted"; export const V2ReviewStartResponse__CollabAgentToolCallStatus = Schema.Literals([ "inProgress", "completed", "failed", + "interrupted", ]); export type V2ReviewStartResponse__CommandExecutionSource = @@ -39598,20 +39955,33 @@ export type V2ThreadForkResponse__CollabAgentTool = | "sendInput" | "resumeAgent" | "wait" - | "closeAgent"; + | "closeAgent" + | "sendMessage" + | "followupTask" + | "interruptAgent" + | "listAgents"; export const V2ThreadForkResponse__CollabAgentTool = Schema.Literals([ "spawnAgent", "sendInput", "resumeAgent", "wait", "closeAgent", + "sendMessage", + "followupTask", + "interruptAgent", + "listAgents", ]); -export type V2ThreadForkResponse__CollabAgentToolCallStatus = "inProgress" | "completed" | "failed"; +export type V2ThreadForkResponse__CollabAgentToolCallStatus = + | "inProgress" + | "completed" + | "failed" + | "interrupted"; export const V2ThreadForkResponse__CollabAgentToolCallStatus = Schema.Literals([ "inProgress", "completed", "failed", + "interrupted", ]); export type V2ThreadForkResponse__CommandExecutionSource = @@ -39955,20 +40325,33 @@ export type V2ThreadListResponse__CollabAgentTool = | "sendInput" | "resumeAgent" | "wait" - | "closeAgent"; + | "closeAgent" + | "sendMessage" + | "followupTask" + | "interruptAgent" + | "listAgents"; export const V2ThreadListResponse__CollabAgentTool = Schema.Literals([ "spawnAgent", "sendInput", "resumeAgent", "wait", "closeAgent", + "sendMessage", + "followupTask", + "interruptAgent", + "listAgents", ]); -export type V2ThreadListResponse__CollabAgentToolCallStatus = "inProgress" | "completed" | "failed"; +export type V2ThreadListResponse__CollabAgentToolCallStatus = + | "inProgress" + | "completed" + | "failed" + | "interrupted"; export const V2ThreadListResponse__CollabAgentToolCallStatus = Schema.Literals([ "inProgress", "completed", "failed", + "interrupted", ]); export type V2ThreadListResponse__CommandExecutionSource = @@ -40131,23 +40514,33 @@ export type V2ThreadMetadataUpdateResponse__CollabAgentTool = | "sendInput" | "resumeAgent" | "wait" - | "closeAgent"; + | "closeAgent" + | "sendMessage" + | "followupTask" + | "interruptAgent" + | "listAgents"; export const V2ThreadMetadataUpdateResponse__CollabAgentTool = Schema.Literals([ "spawnAgent", "sendInput", "resumeAgent", "wait", "closeAgent", + "sendMessage", + "followupTask", + "interruptAgent", + "listAgents", ]); export type V2ThreadMetadataUpdateResponse__CollabAgentToolCallStatus = | "inProgress" | "completed" - | "failed"; + | "failed" + | "interrupted"; export const V2ThreadMetadataUpdateResponse__CollabAgentToolCallStatus = Schema.Literals([ "inProgress", "completed", "failed", + "interrupted", ]); export type V2ThreadMetadataUpdateResponse__CommandExecutionSource = @@ -40265,20 +40658,33 @@ export type V2ThreadReadResponse__CollabAgentTool = | "sendInput" | "resumeAgent" | "wait" - | "closeAgent"; + | "closeAgent" + | "sendMessage" + | "followupTask" + | "interruptAgent" + | "listAgents"; export const V2ThreadReadResponse__CollabAgentTool = Schema.Literals([ "spawnAgent", "sendInput", "resumeAgent", "wait", "closeAgent", + "sendMessage", + "followupTask", + "interruptAgent", + "listAgents", ]); -export type V2ThreadReadResponse__CollabAgentToolCallStatus = "inProgress" | "completed" | "failed"; +export type V2ThreadReadResponse__CollabAgentToolCallStatus = + | "inProgress" + | "completed" + | "failed" + | "interrupted"; export const V2ThreadReadResponse__CollabAgentToolCallStatus = Schema.Literals([ "inProgress", "completed", "failed", + "interrupted", ]); export type V2ThreadReadResponse__CommandExecutionSource = @@ -40964,23 +41370,33 @@ export type V2ThreadResumeResponse__CollabAgentTool = | "sendInput" | "resumeAgent" | "wait" - | "closeAgent"; + | "closeAgent" + | "sendMessage" + | "followupTask" + | "interruptAgent" + | "listAgents"; export const V2ThreadResumeResponse__CollabAgentTool = Schema.Literals([ "spawnAgent", "sendInput", "resumeAgent", "wait", "closeAgent", + "sendMessage", + "followupTask", + "interruptAgent", + "listAgents", ]); export type V2ThreadResumeResponse__CollabAgentToolCallStatus = | "inProgress" | "completed" - | "failed"; + | "failed" + | "interrupted"; export const V2ThreadResumeResponse__CollabAgentToolCallStatus = Schema.Literals([ "inProgress", "completed", "failed", + "interrupted", ]); export type V2ThreadResumeResponse__CommandExecutionSource = @@ -41334,23 +41750,33 @@ export type V2ThreadRollbackResponse__CollabAgentTool = | "sendInput" | "resumeAgent" | "wait" - | "closeAgent"; + | "closeAgent" + | "sendMessage" + | "followupTask" + | "interruptAgent" + | "listAgents"; export const V2ThreadRollbackResponse__CollabAgentTool = Schema.Literals([ "spawnAgent", "sendInput", "resumeAgent", "wait", "closeAgent", + "sendMessage", + "followupTask", + "interruptAgent", + "listAgents", ]); export type V2ThreadRollbackResponse__CollabAgentToolCallStatus = | "inProgress" | "completed" - | "failed"; + | "failed" + | "interrupted"; export const V2ThreadRollbackResponse__CollabAgentToolCallStatus = Schema.Literals([ "inProgress", "completed", "failed", + "interrupted", ]); export type V2ThreadRollbackResponse__CommandExecutionSource = @@ -41673,23 +42099,33 @@ export type V2ThreadStartedNotification__CollabAgentTool = | "sendInput" | "resumeAgent" | "wait" - | "closeAgent"; + | "closeAgent" + | "sendMessage" + | "followupTask" + | "interruptAgent" + | "listAgents"; export const V2ThreadStartedNotification__CollabAgentTool = Schema.Literals([ "spawnAgent", "sendInput", "resumeAgent", "wait", "closeAgent", + "sendMessage", + "followupTask", + "interruptAgent", + "listAgents", ]); export type V2ThreadStartedNotification__CollabAgentToolCallStatus = | "inProgress" | "completed" - | "failed"; + | "failed" + | "interrupted"; export const V2ThreadStartedNotification__CollabAgentToolCallStatus = Schema.Literals([ "inProgress", "completed", "failed", + "interrupted", ]); export type V2ThreadStartedNotification__CommandExecutionSource = @@ -42075,23 +42511,33 @@ export type V2ThreadStartResponse__CollabAgentTool = | "sendInput" | "resumeAgent" | "wait" - | "closeAgent"; + | "closeAgent" + | "sendMessage" + | "followupTask" + | "interruptAgent" + | "listAgents"; export const V2ThreadStartResponse__CollabAgentTool = Schema.Literals([ "spawnAgent", "sendInput", "resumeAgent", "wait", "closeAgent", + "sendMessage", + "followupTask", + "interruptAgent", + "listAgents", ]); export type V2ThreadStartResponse__CollabAgentToolCallStatus = | "inProgress" | "completed" - | "failed"; + | "failed" + | "interrupted"; export const V2ThreadStartResponse__CollabAgentToolCallStatus = Schema.Literals([ "inProgress", "completed", "failed", + "interrupted", ]); export type V2ThreadStartResponse__CommandExecutionSource = @@ -42278,23 +42724,33 @@ export type V2ThreadUnarchiveResponse__CollabAgentTool = | "sendInput" | "resumeAgent" | "wait" - | "closeAgent"; + | "closeAgent" + | "sendMessage" + | "followupTask" + | "interruptAgent" + | "listAgents"; export const V2ThreadUnarchiveResponse__CollabAgentTool = Schema.Literals([ "spawnAgent", "sendInput", "resumeAgent", "wait", "closeAgent", + "sendMessage", + "followupTask", + "interruptAgent", + "listAgents", ]); export type V2ThreadUnarchiveResponse__CollabAgentToolCallStatus = | "inProgress" | "completed" - | "failed"; + | "failed" + | "interrupted"; export const V2ThreadUnarchiveResponse__CollabAgentToolCallStatus = Schema.Literals([ "inProgress", "completed", "failed", + "interrupted", ]); export type V2ThreadUnarchiveResponse__CommandExecutionSource = @@ -42412,23 +42868,33 @@ export type V2TurnCompletedNotification__CollabAgentTool = | "sendInput" | "resumeAgent" | "wait" - | "closeAgent"; + | "closeAgent" + | "sendMessage" + | "followupTask" + | "interruptAgent" + | "listAgents"; export const V2TurnCompletedNotification__CollabAgentTool = Schema.Literals([ "spawnAgent", "sendInput", "resumeAgent", "wait", "closeAgent", + "sendMessage", + "followupTask", + "interruptAgent", + "listAgents", ]); export type V2TurnCompletedNotification__CollabAgentToolCallStatus = | "inProgress" | "completed" - | "failed"; + | "failed" + | "interrupted"; export const V2TurnCompletedNotification__CollabAgentToolCallStatus = Schema.Literals([ "inProgress", "completed", "failed", + "interrupted", ]); export type V2TurnCompletedNotification__CommandExecutionSource = @@ -42524,23 +42990,33 @@ export type V2TurnStartedNotification__CollabAgentTool = | "sendInput" | "resumeAgent" | "wait" - | "closeAgent"; + | "closeAgent" + | "sendMessage" + | "followupTask" + | "interruptAgent" + | "listAgents"; export const V2TurnStartedNotification__CollabAgentTool = Schema.Literals([ "spawnAgent", "sendInput", "resumeAgent", "wait", "closeAgent", + "sendMessage", + "followupTask", + "interruptAgent", + "listAgents", ]); export type V2TurnStartedNotification__CollabAgentToolCallStatus = | "inProgress" | "completed" - | "failed"; + | "failed" + | "interrupted"; export const V2TurnStartedNotification__CollabAgentToolCallStatus = Schema.Literals([ "inProgress", "completed", "failed", + "interrupted", ]); export type V2TurnStartedNotification__CommandExecutionSource = @@ -42728,20 +43204,33 @@ export type V2TurnStartResponse__CollabAgentTool = | "sendInput" | "resumeAgent" | "wait" - | "closeAgent"; + | "closeAgent" + | "sendMessage" + | "followupTask" + | "interruptAgent" + | "listAgents"; export const V2TurnStartResponse__CollabAgentTool = Schema.Literals([ "spawnAgent", "sendInput", "resumeAgent", "wait", "closeAgent", + "sendMessage", + "followupTask", + "interruptAgent", + "listAgents", ]); -export type V2TurnStartResponse__CollabAgentToolCallStatus = "inProgress" | "completed" | "failed"; +export type V2TurnStartResponse__CollabAgentToolCallStatus = + | "inProgress" + | "completed" + | "failed" + | "interrupted"; export const V2TurnStartResponse__CollabAgentToolCallStatus = Schema.Literals([ "inProgress", "completed", "failed", + "interrupted", ]); export type V2TurnStartResponse__CommandExecutionSource = diff --git a/packages/effect-codex-app-server/src/protocol.test.ts b/packages/effect-codex-app-server/src/protocol.test.ts index a7e0397b4adb..7249afff1071 100644 --- a/packages/effect-codex-app-server/src/protocol.test.ts +++ b/packages/effect-codex-app-server/src/protocol.test.ts @@ -3,6 +3,7 @@ import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Queue from "effect/Queue"; import * as Schema from "effect/Schema"; +import * as Stdio from "effect/Stdio"; import * as Stream from "effect/Stream"; import * as NodeServices from "@effect/platform-node/NodeServices"; @@ -290,6 +291,365 @@ it.layer(NodeServices.layer)("effect-codex-app-server protocol", (it) => { }), ); + it.effect("routes a large notification fragmented across thousands of input chunks", () => + Effect.gen(function* () { + const { stdio, input, output } = yield* makeInMemoryStdio(); + const notifications: Array = []; + const transport = yield* CodexProtocol.makeCodexAppServerPatchedProtocol({ + stdio, + onNotification: (notification) => + Effect.sync(() => { + notifications.push(notification); + }), + }); + const response = yield* transport.request("thread/read", {}).pipe(Effect.forkScoped); + yield* Queue.take(output); + + const notification = { + method: "turn/diff/updated", + params: { + threadId: "thread-1", + turnId: "turn-1", + diff: "x".repeat(4 * 1024 * 1024), + }, + }; + const bytes = encoder.encode( + `${encodeUnknownJsonString(notification)}\n${encodeUnknownJsonString({ id: 1, result: { ok: true } })}\n`, + ); + for (let offset = 0; offset < bytes.length; offset += 1024) { + yield* Queue.offer(input, bytes.subarray(offset, offset + 1024)); + } + + assert.deepEqual(yield* Fiber.join(response), { ok: true }); + assert.deepEqual(notifications, [notification]); + }), + ); + + it.effect.each([1, 7, 1024])( + "preserves JSONL framing and UTF-8 across %i-byte input chunks", + (chunkSize) => + Effect.gen(function* () { + const { stdio, input } = yield* makeInMemoryStdio(); + const notifications: Array = []; + const rawLines: Array = []; + const termination = yield* Deferred.make(); + yield* CodexProtocol.makeCodexAppServerPatchedProtocol({ + stdio, + logIncoming: true, + logger: (event) => + Effect.sync(() => { + if (event.stage === "raw") { + rawLines.push(event.payload); + } + }), + onNotification: (notification) => + Effect.sync(() => { + notifications.push(notification); + }), + onTermination: (error) => Deferred.succeed(termination, error).pipe(Effect.asVoid), + }); + + const firstLine = '{"method":"x/first",\r"params":{"text":"hé🙂"}}'; + const secondLine = '{"method":"x/second","params":{"value":2}}'; + const finalLine = '{"method":"x/final","params":{"text":"最後"}}\r'; + const bytes = encoder.encode(`\n \t\r\n${firstLine}\r\n\n${secondLine}\n${finalLine}`); + for (let offset = 0; offset < bytes.length; offset += chunkSize) { + yield* Queue.offer(input, bytes.subarray(offset, offset + chunkSize)); + } + yield* Queue.end(input); + + assert.instanceOf( + yield* Deferred.await(termination), + CodexError.CodexAppServerInputStreamEndedError, + ); + assert.deepEqual(notifications, [ + { method: "x/first", params: { text: "hé🙂" } }, + { method: "x/second", params: { value: 2 } }, + { method: "x/final", params: { text: "最後" } }, + ]); + assert.deepEqual(rawLines, [firstLine, secondLine, finalLine]); + }), + ); + + it.effect("reports a malformed fragmented final line before input stream termination", () => + Effect.gen(function* () { + const { stdio, input, output } = yield* makeInMemoryStdio(); + const termination = yield* Deferred.make(); + const transport = yield* CodexProtocol.makeCodexAppServerPatchedProtocol({ + stdio, + onTermination: (error) => Deferred.succeed(termination, error).pipe(Effect.asVoid), + }); + const response = yield* transport.request("thread/read", {}).pipe(Effect.forkScoped); + yield* Queue.take(output); + + yield* Queue.offer(input, encoder.encode('{"id":1,')); + yield* Queue.offer(input, encoder.encode('"result":')); + yield* Queue.end(input); + + const error = yield* Deferred.await(termination); + assert.instanceOf(error, CodexError.CodexAppServerProtocolParseError); + assert.equal(error.operation, "decode-wire-message"); + const responseError = yield* Fiber.join(response).pipe( + Effect.match({ + onFailure: (failure) => failure, + onSuccess: () => assert.fail("Expected the malformed response to fail the request"), + }), + ); + assert.strictEqual(responseError, error); + }), + ); + + it.effect("keeps only recent raw notifications after their callbacks run", () => + Effect.gen(function* () { + const { stdio, input } = yield* makeInMemoryStdio(); + const handled = yield* Deferred.make(); + let handledCount = 0; + const transport = yield* CodexProtocol.makeCodexAppServerPatchedProtocol({ + stdio, + onNotification: () => + Effect.sync(() => ++handledCount).pipe( + Effect.flatMap((count) => + count === 64 ? Deferred.succeed(handled, undefined).pipe(Effect.asVoid) : Effect.void, + ), + ), + }); + + const messages = Array.from({ length: 64 }, (_, index) => + encodeUnknownJsonString({ + method: "item/agentMessage/delta", + params: { index }, + }), + ); + yield* Queue.offer(input, encoder.encode(`${messages.join("\n")}\n`)); + yield* Deferred.await(handled); + + const retained = yield* transport.incomingNotifications.pipe( + Stream.take(32), + Stream.runCollect, + ); + + assert.equal(handledCount, 64); + assert.equal(retained.length, 32); + assert.deepEqual(retained[0]?.params, { index: 32 }); + assert.deepEqual(retained[31]?.params, { index: 63 }); + }), + ); + + it.effect("keeps processing protocol messages while an approval is pending", () => + Effect.gen(function* () { + const { stdio, input, output } = yield* makeInMemoryStdio(); + const approvalStarted = yield* Deferred.make(); + const approvalDecision = yield* Deferred.make<{ readonly decision: string }>(); + const notificationReceived = yield* Deferred.make(); + const transport = yield* CodexProtocol.makeCodexAppServerPatchedProtocol({ + stdio, + onRequest: () => + Deferred.succeed(approvalStarted, undefined).pipe( + Effect.andThen(Deferred.await(approvalDecision)), + ), + onNotification: () => Deferred.succeed(notificationReceived, undefined).pipe(Effect.asVoid), + }); + + const pendingRequest = yield* transport.request("thread/read", {}).pipe(Effect.forkScoped); + yield* Queue.take(output); + yield* Queue.offer( + input, + encoder.encode( + `${[ + encodeUnknownJsonString({ id: 7, method: "item/tool/requestUserInput", params: {} }), + encodeUnknownJsonString({ method: "item/agentMessage/delta", params: { delta: "ok" } }), + encodeUnknownJsonString({ id: 1, result: { threadId: "thread-1" } }), + ].join("\n")}\n`, + ), + ); + + yield* Deferred.await(approvalStarted); + yield* Deferred.await(notificationReceived); + assert.deepEqual(yield* Fiber.join(pendingRequest), { threadId: "thread-1" }); + + yield* Deferred.succeed(approvalDecision, { decision: "accept" }); + assert.deepEqual(yield* decodeJson(yield* Queue.take(output)), { + id: 7, + result: { decision: "accept" }, + }); + }), + ); + + it.effect("rejects incoming requests after the active handler limit is reached", () => + Effect.gen(function* () { + const { stdio, input, output } = yield* makeInMemoryStdio(); + const handlersStarted = yield* Deferred.make(); + const releaseHandlers = yield* Deferred.make(); + let activeHandlers = 0; + yield* CodexProtocol.makeCodexAppServerPatchedProtocol({ + stdio, + onRequest: () => + Effect.sync(() => ++activeHandlers).pipe( + Effect.flatMap((count) => + count === 32 + ? Deferred.succeed(handlersStarted, undefined).pipe(Effect.asVoid) + : Effect.void, + ), + Effect.andThen(Deferred.await(releaseHandlers)), + Effect.as({ decision: "accept" }), + ), + }); + + const requests = Array.from({ length: 33 }, (_, index) => + encodeUnknownJsonString({ + id: index + 1, + method: "item/tool/requestUserInput", + params: {}, + }), + ); + yield* Queue.offer(input, encoder.encode(`${requests.join("\n")}\n`)); + yield* Deferred.await(handlersStarted); + + assert.deepEqual(yield* decodeJson(yield* Queue.take(output)), { + id: 33, + error: { + code: -32001, + message: "Too many Codex requests are already active.", + }, + }); + assert.equal(activeHandlers, 32); + + yield* Deferred.succeed(releaseHandlers, undefined); + yield* Effect.forEach(Array.from({ length: 32 }), () => Queue.take(output), { + discard: true, + }); + }), + ); + + it.effect("interrupts pending request handlers when the protocol terminates", () => + Effect.gen(function* () { + const { stdio, input } = yield* makeInMemoryStdio(); + const approvalStarted = yield* Deferred.make(); + const approvalInterrupted = yield* Deferred.make(); + const terminated = yield* Deferred.make(); + yield* CodexProtocol.makeCodexAppServerPatchedProtocol({ + stdio, + onRequest: () => + Deferred.succeed(approvalStarted, undefined).pipe( + Effect.andThen(Effect.never), + Effect.onInterrupt(() => + Deferred.succeed(approvalInterrupted, undefined).pipe(Effect.asVoid), + ), + ), + onTermination: () => Deferred.succeed(terminated, undefined).pipe(Effect.asVoid), + }); + + yield* Queue.offer( + input, + encodeJsonl({ id: 7, method: "item/tool/requestUserInput", params: {} }), + ); + yield* Deferred.await(approvalStarted); + yield* Queue.end(input); + + yield* Deferred.await(approvalInterrupted); + yield* Deferred.await(terminated); + }), + ); + + it.effect("rejects outgoing messages after an approval response cannot be encoded", () => + Effect.gen(function* () { + const { stdio: baseStdio, input } = yield* makeInMemoryStdio(); + const terminated = yield* Deferred.make(); + const readerStopped = yield* Deferred.make(); + let notificationCount = 0; + let requestCount = 0; + const stdio = Stdio.make({ + args: baseStdio.args, + stdin: baseStdio.stdin.pipe( + Stream.ensuring(Deferred.succeed(readerStopped, undefined).pipe(Effect.asVoid)), + ), + stdout: baseStdio.stdout, + stderr: baseStdio.stderr, + }); + const transport = yield* CodexProtocol.makeCodexAppServerPatchedProtocol({ + stdio, + onRequest: () => + Effect.sync(() => ++requestCount).pipe( + Effect.map((count) => (count === 1 ? { invalid: 1n } : { ok: true })), + ), + onNotification: () => Effect.sync(() => notificationCount++).pipe(Effect.asVoid), + onTermination: (error) => Deferred.succeed(terminated, error).pipe(Effect.asVoid), + }); + + yield* Queue.offer( + input, + encodeJsonl({ id: 7, method: "item/tool/requestUserInput", params: {} }), + ); + + const failure = yield* Deferred.await(terminated); + assert.instanceOf(failure, CodexError.CodexAppServerProtocolParseError); + const requestFailure = yield* transport.request("thread/read", {}).pipe( + Effect.match({ + onFailure: (error) => error, + onSuccess: () => assert.fail("Expected a terminated protocol request to fail"), + }), + ); + const notificationFailure = yield* transport.notify("initialized").pipe(Effect.flip); + assert.strictEqual(requestFailure, failure); + assert.strictEqual(notificationFailure, failure); + yield* Deferred.await(readerStopped); + + yield* Queue.offer( + input, + encoder.encode( + `${[ + encodeUnknownJsonString({ method: "x/late-notification" }), + encodeUnknownJsonString({ id: 8, method: "x/late-request" }), + ].join("\n")}\n`, + ), + ); + + assert.equal(notificationCount, 0); + assert.equal(requestCount, 1); + assert.equal(yield* Queue.size(input), 1); + }), + ); + + it.effect("fails pending requests before interrupted handler cleanup completes", () => + Effect.gen(function* () { + const { stdio, input, output } = yield* makeInMemoryStdio(); + const handlerStarted = yield* Deferred.make(); + const finalizerStarted = yield* Deferred.make(); + const releaseFinalizer = yield* Deferred.make(); + const terminated = yield* Deferred.make(); + const transport = yield* CodexProtocol.makeCodexAppServerPatchedProtocol({ + stdio, + onRequest: () => + Deferred.succeed(handlerStarted, undefined).pipe( + Effect.andThen(Effect.never), + Effect.onInterrupt(() => + Deferred.succeed(finalizerStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseFinalizer)), + ), + ), + ), + onTermination: (error) => Deferred.succeed(terminated, error).pipe(Effect.asVoid), + }); + const pending = yield* transport.request("thread/read", {}).pipe(Effect.forkScoped); + yield* Queue.take(output); + yield* Queue.offer(input, encodeJsonl({ id: 7, method: "x/approval" })); + yield* Deferred.await(handlerStarted); + yield* Queue.end(input); + + const failure = yield* Deferred.await(terminated); + yield* Deferred.await(finalizerStarted); + const pendingFailure = yield* Fiber.join(pending).pipe( + Effect.match({ + onFailure: (error) => error, + onSuccess: () => assert.fail("Expected the pending request to fail"), + }), + ); + assert.strictEqual(pendingFailure, failure); + + yield* Deferred.succeed(releaseFinalizer, undefined); + }), + ); + it.effect("surfaces JSON encoding failures as protocol parse errors", () => Effect.gen(function* () { const { stdio } = yield* makeInMemoryStdio(); diff --git a/packages/effect-codex-app-server/src/protocol.ts b/packages/effect-codex-app-server/src/protocol.ts index 17bfaed2b64c..4a32973a988b 100644 --- a/packages/effect-codex-app-server/src/protocol.ts +++ b/packages/effect-codex-app-server/src/protocol.ts @@ -1,6 +1,8 @@ import * as Cause from "effect/Cause"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as Scope from "effect/Scope"; @@ -13,6 +15,7 @@ import { JsonRpcId, JsonRpcResponseEnvelope } from "./_internal/shared.ts"; const isJsonRpcId = Schema.is(JsonRpcId); const isJsonRpcResponseEnvelope = Schema.is(JsonRpcResponseEnvelope); const isCodexAppServerError = Schema.is(CodexError.CodexAppServerError); +const MAX_BUFFERED_RAW_MESSAGES = 32; export interface CodexAppServerProtocolLogEvent { readonly direction: "incoming" | "outgoing"; @@ -152,13 +155,20 @@ export const makeCodexAppServerPatchedProtocol = Effect.fn("makeCodexAppServerPa function* ( options: CodexAppServerPatchedProtocolOptions, ): Effect.fn.Return { + const protocolScope = yield* Scope.Scope; + const requestHandlerScope = yield* Scope.fork(protocolScope, "parallel"); const outgoing = yield* Queue.unbounded>(); - const incomingNotifications = yield* Queue.unbounded(); - const incomingRequests = yield* Queue.unbounded(); + const incomingNotifications = + yield* Queue.sliding(MAX_BUFFERED_RAW_MESSAGES); + const incomingRequests = + yield* Queue.sliding(MAX_BUFFERED_RAW_MESSAGES); const pending = yield* Ref.make(new Map()); const nextRequestId = yield* Ref.make(1); - const remainder = yield* Ref.make(""); + const remainder: Array = []; const terminationHandled = yield* Ref.make(false); + const terminationFailure = yield* Ref.make(Option.none()); + const terminationSignal = yield* Deferred.make(); + const activeRequestHandlers = yield* Ref.make(0); const logProtocol = (event: CodexAppServerProtocolLogEvent) => { if (event.direction === "incoming" && !options.logIncoming) { @@ -191,8 +201,14 @@ export const makeCodexAppServerPatchedProtocol = Effect.fn("makeCodexAppServerPa return [ Effect.gen(function* () { const error = yield* classify(); + yield* Ref.set(terminationFailure, Option.some(error)); yield* failAllPending(error); yield* Queue.end(outgoing); + yield* Deferred.succeed(terminationSignal, undefined); + yield* Scope.close(requestHandlerScope, Exit.void).pipe( + Effect.forkIn(protocolScope, { startImmediately: true }), + Effect.asVoid, + ); if (options.onTermination) { yield* options.onTermination(error); } @@ -203,6 +219,9 @@ export const makeCodexAppServerPatchedProtocol = Effect.fn("makeCodexAppServerPa const offerOutgoing = (message: Record) => Effect.gen(function* () { + const failure = yield* Ref.get(terminationFailure); + if (Option.isSome(failure)) return yield* failure.value; + yield* logProtocol({ direction: "outgoing", stage: "decoded", @@ -214,7 +233,14 @@ export const makeCodexAppServerPatchedProtocol = Effect.fn("makeCodexAppServerPa stage: "raw", payload: encoded, }); - yield* Queue.offer(outgoing, encoded).pipe(Effect.asVoid); + const accepted = yield* Queue.offer(outgoing, encoded); + if (!accepted) { + const closed = yield* Ref.get(terminationFailure); + return yield* Option.getOrElse( + closed, + () => new CodexError.CodexAppServerInputStreamEndedError({}), + ); + } }); const removePending = (requestId: string) => @@ -271,9 +297,24 @@ export const makeCodexAppServerPatchedProtocol = Effect.fn("makeCodexAppServerPa const handleRequest = (request: CodexAppServerIncomingRequest) => Queue.offer(incomingRequests, request).pipe( - Effect.andThen( - options.onRequest - ? options.onRequest(request).pipe( + Effect.flatMap(() => { + const handler = options.onRequest; + if (!handler) return Effect.void; + + return Ref.modify(activeRequestHandlers, (count) => + count >= MAX_BUFFERED_RAW_MESSAGES ? [false, count] : [true, count + 1], + ).pipe( + Effect.flatMap((accepted) => { + if (!accepted) { + return respondError( + request.id, + CodexError.CodexAppServerRequestError.overloaded( + "Too many Codex requests are already active.", + ), + ); + } + + return handler(request).pipe( Effect.matchEffect({ onFailure: (error) => respondError( @@ -285,9 +326,21 @@ export const makeCodexAppServerPatchedProtocol = Effect.fn("makeCodexAppServerPa ), onSuccess: (result) => respond(request.id, result), }), - ) - : Effect.void, - ), + Effect.ensuring( + Ref.update(activeRequestHandlers, (count) => Math.max(0, count - 1)), + ), + Effect.catch((error) => + handleTermination(() => Effect.succeed(error)).pipe( + Effect.forkIn(protocolScope), + Effect.asVoid, + ), + ), + Effect.forkIn(requestHandlerScope, { startImmediately: true }), + Effect.asVoid, + ); + }), + ); + }), Effect.asVoid, ); @@ -297,22 +350,13 @@ export const makeCodexAppServerPatchedProtocol = Effect.fn("makeCodexAppServerPa Effect.asVoid, ); - const routeMessage = ( - message: unknown, - ): Effect.Effect => { - if (isIncomingRequest(message)) { - return handleRequest(message); - } - if (isIncomingNotification(message)) { - return handleNotification(message); - } - if (isIncomingResponse(message)) { - return handleResponse(message); - } - return Effect.fail( - CodexError.CodexAppServerProtocolParseError.fromUnroutableMessage(message), - ); - }; + const routeMessage = Effect.fnUntraced(function* (message: unknown) { + if (Option.isSome(yield* Ref.get(terminationFailure))) return; + if (isIncomingRequest(message)) return yield* handleRequest(message); + if (isIncomingNotification(message)) return yield* handleNotification(message); + if (isIncomingResponse(message)) return yield* handleResponse(message); + return yield* CodexError.CodexAppServerProtocolParseError.fromUnroutableMessage(message); + }); const handleLine = (line: string): Effect.Effect => { if (line.trim().length === 0) { @@ -352,13 +396,27 @@ export const makeCodexAppServerPatchedProtocol = Effect.fn("makeCodexAppServerPa }; yield* options.stdio.stdin.pipe( + Stream.interruptWhen(Deferred.await(terminationSignal)), Stream.decodeText(), Stream.runForEach((chunk) => - Ref.modify(remainder, (current) => { - const combined = current + chunk; - const lines = combined.split("\n"); - const nextRemainder = lines.pop() ?? ""; - return [lines.map((line) => line.replace(/\r$/, "")), nextRemainder] as const; + Effect.sync(() => { + const lines: Array = []; + let start = 0; + for ( + let newline = chunk.indexOf("\n"); + newline !== -1; + newline = chunk.indexOf("\n", start) + ) { + remainder.push(chunk.slice(start, newline)); + lines.push(remainder.join("").replace(/\r$/, "")); + remainder.length = 0; + start = newline + 1; + } + // Keep unfinished lines in fragments so each chunk is scanned only once. + if (start < chunk.length) { + remainder.push(chunk.slice(start)); + } + return lines; }).pipe(Effect.flatMap((lines) => Effect.forEach(lines, handleLine, { discard: true }))), ), Effect.matchEffect({ @@ -367,8 +425,12 @@ export const makeCodexAppServerPatchedProtocol = Effect.fn("makeCodexAppServerPa Effect.succeed(normalizeIncomingError(error, "read-input-stream")), ), onSuccess: () => - Ref.get(remainder).pipe( - Effect.flatMap((line) => (line.trim().length === 0 ? Effect.void : handleLine(line))), + Effect.sync(() => { + const line = remainder.join(""); + remainder.length = 0; + return line; + }).pipe( + Effect.flatMap(handleLine), Effect.matchEffect({ onFailure: (error) => handleTermination(() => Effect.succeed(error)), onSuccess: () => diff --git a/packages/effect-codex-app-server/src/schema.test.ts b/packages/effect-codex-app-server/src/schema.test.ts new file mode 100644 index 000000000000..d7afec7db268 --- /dev/null +++ b/packages/effect-codex-app-server/src/schema.test.ts @@ -0,0 +1,98 @@ +import { assert, it } from "@effect/vitest"; +import * as Schema from "effect/Schema"; + +import * as CodexSchema from "./schema.ts"; + +const isGetAccountResponse = Schema.is(CodexSchema.V2GetAccountResponse); + +it("accepts Codex 0.150 multi-agent values", () => { + const schemas = [ + CodexSchema.ServerNotification__SubAgentActivityKind, + CodexSchema.V2ItemStartedNotification__SubAgentActivityKind, + CodexSchema.V2ItemCompletedNotification__SubAgentActivityKind, + CodexSchema.V2ThreadReadResponse__SubAgentActivityKind, + CodexSchema.V2ThreadResumeResponse__SubAgentActivityKind, + ]; + + for (const schema of schemas) { + assert.equal(Schema.is(schema)("completed"), true); + } + + for (const tool of ["sendMessage", "followupTask", "interruptAgent", "listAgents"]) { + assert.equal(Schema.is(CodexSchema.ServerNotification__CollabAgentTool)(tool), true); + assert.equal(Schema.is(CodexSchema.V2ThreadResumeResponse__CollabAgentTool)(tool), true); + } + + assert.equal( + Schema.is(CodexSchema.ServerNotification__CollabAgentToolCallStatus)("interrupted"), + true, + ); + assert.equal( + Schema.is(CodexSchema.V2ThreadResumeResponse__CollabAgentToolCallStatus)("interrupted"), + true, + ); + + const resumeResponse = { + approvalPolicy: "never", + approvalsReviewer: "user", + cwd: "/tmp/project", + model: "gpt-5.6-sol", + modelProvider: "openai", + sandbox: { type: "dangerFullAccess" }, + thread: { + cliVersion: "0.150.0", + createdAt: 0, + cwd: "/tmp/project", + ephemeral: false, + id: "root-thread", + modelProvider: "openai", + preview: "", + sessionId: "session-1", + source: "cli", + status: { type: "idle" }, + turns: [ + { + id: "turn-1", + status: "completed", + items: [ + { + agentsStates: {}, + id: "item-1", + receiverThreadIds: ["child-thread"], + senderThreadId: "root-thread", + status: "interrupted", + tool: "followupTask", + type: "collabAgentToolCall", + }, + ], + }, + ], + updatedAt: 0, + }, + }; + + assert.equal(Schema.is(CodexSchema.V2ThreadResumeResponse)(resumeResponse), true); +}); + +it("accepts Codex 0.150 account plan values", () => { + const planTypes = [ + "self_serve_business_prolite", + "ent26", + "enterprise_cbp_automation", + "edu_plus", + "edu_pro", + ]; + + for (const planType of planTypes) { + const accountResponse = { + account: { + email: "user@example.com", + planType, + type: "chatgpt", + }, + requiresOpenaiAuth: true, + }; + + assert.equal(isGetAccountResponse(accountResponse), true); + } +}); diff --git a/packages/shared/package.json b/packages/shared/package.json index a797e97b6625..d7e8c9e91265 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -167,6 +167,10 @@ "types": "./src/keybindings.ts", "import": "./src/keybindings.ts" }, + "./threadReference": { + "types": "./src/threadReference.ts", + "import": "./src/threadReference.ts" + }, "./composerTrigger": { "types": "./src/composerTrigger.ts", "import": "./src/composerTrigger.ts" @@ -199,6 +203,10 @@ "types": "./src/filePreview.ts", "import": "./src/filePreview.ts" }, + "./video": { + "types": "./src/video.ts", + "import": "./src/video.ts" + }, "./chatList": { "types": "./src/chatList.ts", "import": "./src/chatList.ts" @@ -226,6 +234,14 @@ "./usageFormat": { "types": "./src/usageFormat.ts", "import": "./src/usageFormat.ts" + }, + "./desktopAppControl": { + "types": "./src/desktopAppControl.ts", + "import": "./src/desktopAppControl.ts" + }, + "./claudeCompaction": { + "types": "./src/claudeCompaction.ts", + "import": "./src/claudeCompaction.ts" } }, "scripts": { diff --git a/packages/shared/src/String.test.ts b/packages/shared/src/String.test.ts deleted file mode 100644 index a32b6f28ebf4..000000000000 --- a/packages/shared/src/String.test.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { truncate } from "./String.ts"; - -describe("truncate", () => { - it("trims surrounding whitespace", () => { - expect(truncate(" hello world ")).toBe("hello world"); - }); - - it("returns shorter strings unchanged", () => { - expect(truncate("alpha", 10)).toBe("alpha"); - }); - - it("truncates long strings and appends an ellipsis", () => { - expect(truncate("abcdefghij", 5)).toBe("abcde..."); - }); -}); diff --git a/packages/shared/src/claudeCompaction.test.ts b/packages/shared/src/claudeCompaction.test.ts new file mode 100644 index 000000000000..9eef52520719 --- /dev/null +++ b/packages/shared/src/claudeCompaction.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + formatClaudeResumeCompactionQuestion, + isClaudeResumeCompactionQuestion, +} from "./claudeCompaction.ts"; + +describe("claude resume compaction copy", () => { + // The matcher must recognize every question the formatter can produce. + // This is the drift guard: rewording one side fails here. + it.each([ + { ageMinutes: 145, estimatedTokens: 275_123 }, + { ageMinutes: 70, estimatedTokens: 100_000 }, + { ageMinutes: 59, estimatedTokens: 1_234_567 }, + { ageMinutes: 0, estimatedTokens: 0 }, + ])("matches its own formatted question (%o)", (input) => { + const question = formatClaudeResumeCompactionQuestion(input); + expect(isClaudeResumeCompactionQuestion(question)).toBe(true); + }); + + it("formats ages above and below one hour", () => { + expect( + formatClaudeResumeCompactionQuestion({ ageMinutes: 145, estimatedTokens: 275_123 }), + ).toBe("This session is 2h 25m old and uses 275,123 tokens. Compact it before continuing?"); + expect(formatClaudeResumeCompactionQuestion({ ageMinutes: 45, estimatedTokens: 1_000 })).toBe( + "This session is 45m old and uses 1,000 tokens. Compact it before continuing?", + ); + }); + + it("does not match unrelated questions", () => { + expect( + isClaudeResumeCompactionQuestion("The build cache is large. Compact it before continuing?"), + ).toBe(false); + }); +}); diff --git a/packages/shared/src/claudeCompaction.ts b/packages/shared/src/claudeCompaction.ts new file mode 100644 index 000000000000..cadb870e8dc2 --- /dev/null +++ b/packages/shared/src/claudeCompaction.ts @@ -0,0 +1,26 @@ +/** + * Copy for Claude's resume compaction dialog, shared by the server adapter + * (which asks the question) and the web client (which recognizes the + * question and its "never" answer in resolved user-input activities to + * mirror the dismissal). Both sides must agree on these strings, so they + * live here: reword the question or the answer label in this file only. + */ +export const CLAUDE_RESUME_COMPACTION_NEVER_ANSWER = "Don't ask again"; + +export function formatClaudeResumeCompactionQuestion(input: { + readonly ageMinutes: number; + readonly estimatedTokens: number; +}): string { + const ageLabel = + input.ageMinutes >= 60 + ? `${Math.floor(input.ageMinutes / 60)}h ${input.ageMinutes % 60}m` + : `${input.ageMinutes}m`; + return `This session is ${ageLabel} old and uses ${input.estimatedTokens.toLocaleString("en-US")} tokens. Compact it before continuing?`; +} + +const CLAUDE_RESUME_COMPACTION_QUESTION_PATTERN = + /^This session is (?:\d+h \d+m|\d+m) old and uses \d{1,3}(?:,\d{3})* tokens\. Compact it before continuing\?$/u; + +export function isClaudeResumeCompactionQuestion(question: string): boolean { + return CLAUDE_RESUME_COMPACTION_QUESTION_PATTERN.test(question); +} diff --git a/packages/shared/src/desktopAppControl.test.ts b/packages/shared/src/desktopAppControl.test.ts new file mode 100644 index 000000000000..cd50b7ae0bce --- /dev/null +++ b/packages/shared/src/desktopAppControl.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { resolveDesktopAppControlAddress } from "./desktopAppControl.ts"; + +describe("resolveDesktopAppControlAddress", () => { + it("keeps Unix socket paths short and separates desktop state directories", () => { + const first = resolveDesktopAppControlAddress({ + stateDir: `/home/user/${"long/".repeat(40)}userdata`, + platform: "linux", + tempDir: "/tmp", + userId: 1000, + joinPath: (...segments) => segments.join("/"), + }); + const second = resolveDesktopAppControlAddress({ + stateDir: "/home/user/.t3/other/userdata", + platform: "linux", + tempDir: "/tmp", + userId: 1000, + joinPath: (...segments) => segments.join("/"), + }); + + expect(first.directory).toBe("/tmp/t3code-1000"); + expect(first.address.length).toBeLessThan(108); + expect(first.address).not.toBe(second.address); + }); + + it("uses a Windows named pipe", () => { + const result = resolveDesktopAppControlAddress({ + stateDir: "C:\\Users\\user\\.t3\\userdata", + platform: "win32", + tempDir: "C:\\Temp", + userId: undefined, + joinPath: (...segments) => segments.join("\\"), + }); + + expect(result.directory).toBeNull(); + expect(result.address).toMatch(/^\\\\\.\\pipe\\t3code-app-[a-f0-9]{24}$/); + }); +}); diff --git a/packages/shared/src/desktopAppControl.ts b/packages/shared/src/desktopAppControl.ts new file mode 100644 index 000000000000..42fc391d8bdb --- /dev/null +++ b/packages/shared/src/desktopAppControl.ts @@ -0,0 +1,41 @@ +import { sha256 } from "@noble/hashes/sha2"; + +export interface DesktopAppControlAddress { + readonly address: string; + readonly directory: string | null; +} + +function shortHash(value: string): string { + return Array.from(sha256(new TextEncoder().encode(value)).slice(0, 12), (byte) => + byte.toString(16).padStart(2, "0"), + ).join(""); +} + +/** + * Returns the local-only socket address shared by the desktop shell and CLI. + * The state directory is hashed so custom T3 homes cannot exceed Unix socket + * path limits. + */ +export function resolveDesktopAppControlAddress(input: { + readonly stateDir: string; + readonly platform: NodeJS.Platform; + readonly tempDir: string; + readonly userId: number | undefined; + readonly joinPath: (...segments: readonly string[]) => string; +}): DesktopAppControlAddress { + const stateHash = shortHash(input.stateDir); + if (input.platform === "win32") { + return { + address: `\\\\.\\pipe\\t3code-app-${stateHash}`, + directory: null, + }; + } + + const userKey = + input.userId === undefined ? shortHash(input.stateDir).slice(0, 12) : input.userId; + const directory = input.joinPath(input.tempDir, `t3code-${userKey}`); + return { + address: input.joinPath(directory, `${stateHash}.sock`), + directory, + }; +} diff --git a/packages/shared/src/dpop.test.ts b/packages/shared/src/dpop.test.ts index c4ba298f66c5..c7bc6ff3028d 100644 --- a/packages/shared/src/dpop.test.ts +++ b/packages/shared/src/dpop.test.ts @@ -145,6 +145,43 @@ describe("verifyDpopProof", () => { ); }); + it("reports a time-window failure only after the proof signature is valid", () => { + const thumbprint = computeDpopJwkThumbprint(publicJwk); + const outsideWindow = verifyDpopProof({ + proof, + method: "POST", + url: "https://example.com/oauth/token", + nowEpochSeconds: 1_000, + expectedThumbprint: thumbprint, + }); + if (outsideWindow.ok) { + assert.fail("Expected an old DPoP proof to fail."); + } + assert.equal(outsideWindow.code, "time_window"); + + const { privateKey: otherPrivateKey } = NodeCrypto.generateKeyPairSync("ec", { + namedCurve: "P-256", + }); + const invalidSignatureProof = signDpopProof({ + method: "POST", + url: "https://example.com/oauth/token", + iat: 100, + privateKey: otherPrivateKey, + publicJwk, + }); + const invalidSignature = verifyDpopProof({ + proof: invalidSignatureProof, + method: "POST", + url: "https://example.com/oauth/token", + nowEpochSeconds: 1_000, + expectedThumbprint: thumbprint, + }); + if (invalidSignature.ok) { + assert.fail("Expected a proof signed by a different key to fail."); + } + assert.equal(invalidSignature.code, "invalid_signature"); + }); + it("requires the RFC 9449 access token hash when an access token is expected", () => { const thumbprint = computeDpopJwkThumbprint(publicJwk); const accessTokenProof = signDpopProof({ diff --git a/packages/shared/src/dpop.ts b/packages/shared/src/dpop.ts index dabfaffa4cdd..46f2e8f8fa1d 100644 --- a/packages/shared/src/dpop.ts +++ b/packages/shared/src/dpop.ts @@ -17,6 +17,19 @@ export const DpopPublicJwk = DpopPublicJwkSchema; export type DpopPublicJwk = DpopPublicJwkType; export { normalizeDpopHtu }; +export const DpopVerificationFailureCode = Schema.Literals([ + "missing_proof", + "malformed_proof", + "key_mismatch", + "method_mismatch", + "url_mismatch", + "access_token_hash_mismatch", + "time_window", + "invalid_signature", + "invalid_proof", +]); +export type DpopVerificationFailureCode = typeof DpopVerificationFailureCode.Type; + const DpopJwtHeaderPublicJwk = Schema.Struct({ ...DpopPublicJwkSchema.fields, d: Schema.optionalKey(Schema.Never), @@ -51,6 +64,7 @@ export type DpopVerificationResult = } | { readonly ok: false; + readonly code: DpopVerificationFailureCode; readonly reason: string; }; @@ -106,50 +120,46 @@ export function verifyDpopProof(input: { readonly maxAgeSeconds?: number; }): DpopVerificationResult { if (!input.proof?.trim()) { - return { ok: false, reason: "Missing DPoP proof." }; + return { ok: false, code: "missing_proof", reason: "Missing DPoP proof." }; } const parts = input.proof.split("."); if (parts.length !== 3 || !parts[0] || !parts[1] || !parts[2]) { - return { ok: false, reason: "Invalid DPoP compact JWT." }; + return { ok: false, code: "malformed_proof", reason: "Invalid DPoP compact JWT." }; } try { const header = decodeBase64UrlDpopJwtHeader(parts[0]); const payload = decodeBase64UrlDpopJwtPayload(parts[1]); if (Option.isNone(header)) { - return { ok: false, reason: "Invalid DPoP JWT header." }; + return { ok: false, code: "malformed_proof", reason: "Invalid DPoP JWT header." }; } if (Option.isNone(payload)) { - return { ok: false, reason: "Invalid DPoP JWT payload." }; + return { ok: false, code: "malformed_proof", reason: "Invalid DPoP JWT payload." }; } const thumbprint = computeDpopJwkThumbprint(header.value.jwk); if (input.expectedThumbprint && thumbprint !== input.expectedThumbprint) { - return { ok: false, reason: "DPoP key thumbprint mismatch." }; + return { ok: false, code: "key_mismatch", reason: "DPoP key thumbprint mismatch." }; } if (payload.value.htm.toUpperCase() !== input.method.toUpperCase()) { - return { ok: false, reason: "DPoP method mismatch." }; + return { ok: false, code: "method_mismatch", reason: "DPoP method mismatch." }; } const normalizedHtu = normalizeDpopHtu(input.url); if (normalizedHtu === null || payload.value.htu !== normalizedHtu) { - return { ok: false, reason: "DPoP URL mismatch." }; + return { ok: false, code: "url_mismatch", reason: "DPoP URL mismatch." }; } if (input.expectedAccessToken) { const expectedAth = computeDpopAccessTokenHash(input.expectedAccessToken); if (payload.value.ath !== expectedAth) { - return { ok: false, reason: "DPoP access token hash mismatch." }; + return { + ok: false, + code: "access_token_hash_mismatch", + reason: "DPoP access token hash mismatch.", + }; } } - const maxAgeSeconds = input.maxAgeSeconds ?? DEFAULT_MAX_AGE_SECONDS; - if ( - payload.value.iat > input.nowEpochSeconds + 5 || - input.nowEpochSeconds - payload.value.iat > maxAgeSeconds - ) { - return { ok: false, reason: "DPoP proof is outside the allowed time window." }; - } - const signature = base64UrlToBytes(parts[2]); const signatureInputHash = sha256(new TextEncoder().encode(`${parts[0]}.${parts[1]}`)); const verified = p256.verify( @@ -161,15 +171,29 @@ export function verifyDpopProof(input: { format: "compact", }, ); - return verified - ? { - ok: true, - thumbprint, - jti: payload.value.jti, - iat: payload.value.iat, - } - : { ok: false, reason: "Invalid DPoP signature." }; + if (!verified) { + return { ok: false, code: "invalid_signature", reason: "Invalid DPoP signature." }; + } + + const maxAgeSeconds = input.maxAgeSeconds ?? DEFAULT_MAX_AGE_SECONDS; + if ( + payload.value.iat > input.nowEpochSeconds + 5 || + input.nowEpochSeconds - payload.value.iat > maxAgeSeconds + ) { + return { + ok: false, + code: "time_window", + reason: "DPoP proof is outside the allowed time window.", + }; + } + + return { + ok: true, + thumbprint, + jti: payload.value.jti, + iat: payload.value.iat, + }; } catch { - return { ok: false, reason: "Invalid DPoP proof." }; + return { ok: false, code: "invalid_proof", reason: "Invalid DPoP proof." }; } } diff --git a/packages/shared/src/filePreview.test.ts b/packages/shared/src/filePreview.test.ts index eb8b7d1e8926..4aa466159af9 100644 --- a/packages/shared/src/filePreview.test.ts +++ b/packages/shared/src/filePreview.test.ts @@ -4,6 +4,8 @@ import { isWorkspaceBrowserPreviewPath, isWorkspaceImagePreviewPath, isWorkspacePreviewEntryPath, + isWorkspaceVideoPreviewPath, + mediaKindFromPath, } from "./filePreview.ts"; describe("workspace file previews", () => { @@ -34,3 +36,28 @@ describe("workspace file previews", () => { }, ); }); + +describe("media path parsing", () => { + it.each([ + ["https://cdn.example/clip.webm?download=1#t=2", "video"], + ["https://example.com/download?name=recording.mp4", null], + ["https://example.png", null], + ["images%2Fresult%2Epng", "image"], + ["images/result%23v2.png", "image"], + ["images/result.png%23secret.txt", null], + ["images/result.png%3Fsecret.txt", null], + ["/tmp/100%.png", "image"], + ])("classifies the decoded pathname of %s", (source, kind) => { + expect(mediaKindFromPath(source)).toBe(kind); + }); + + it.each([ + ["recording.mp4#t=2", "video", false], + ["recording%2Emp4", "video", false], + ["recording#take2.mp4", null, true], + ["recording?take2.mp4", null, true], + ])("distinguishes authored URLs from literal filenames in %s", (source, kind, literalVideo) => { + expect(mediaKindFromPath(source)).toBe(kind); + expect(isWorkspaceVideoPreviewPath(source)).toBe(literalVideo); + }); +}); diff --git a/packages/shared/src/filePreview.ts b/packages/shared/src/filePreview.ts index c9d15e14c3b7..1d1e41c2865a 100644 --- a/packages/shared/src/filePreview.ts +++ b/packages/shared/src/filePreview.ts @@ -1,3 +1,5 @@ +import { videoMimeType } from "./video.ts"; + export const WORKSPACE_BROWSER_PREVIEW_EXTENSIONS = [".htm", ".html", ".pdf"] as const; export const WORKSPACE_IMAGE_PREVIEW_EXTENSIONS = [ @@ -11,6 +13,57 @@ export const WORKSPACE_IMAGE_PREVIEW_EXTENSIONS = [ ".webp", ] as const; +const IMAGE_MIME_TYPE_BY_EXTENSION = new Map([ + [".avif", "image/avif"], + [".gif", "image/gif"], + [".ico", "image/x-icon"], + [".jpeg", "image/jpeg"], + [".jpg", "image/jpeg"], + [".png", "image/png"], + [".svg", "image/svg+xml"], + [".webp", "image/webp"], +]); + +/** Classifies a literal filesystem extension, without URL decoding or suffix removal. */ +export function mediaMimeTypeFromExtension(extension: string): string | null { + if (!/^\.[a-z0-9]+$/i.test(extension)) return null; + return ( + IMAGE_MIME_TYPE_BY_EXTENSION.get(extension.toLowerCase()) ?? + videoMimeType({ name: `media${extension}`, mimeType: "" }) + ); +} + +/** Classifies an authored media path or URL. Filesystem validation uses the literal extension. */ +export function mediaMimeType(path: string): string | null { + const trimmed = path.trim(); + const source = trimmed.startsWith("<") && trimmed.endsWith(">") ? trimmed.slice(1, -1) : trimmed; + const dataMimeType = /^data:((?:image|video)\/[\w.+-]+)[;,]/i.exec(source)?.[1]; + if (dataMimeType) return dataMimeType.toLowerCase(); + + let sourcePath = source.split(/[?#]/, 1)[0] ?? ""; + if (/^(?:https?:|file:|\/\/)/i.test(source)) { + try { + sourcePath = new URL(source, "https://media.invalid").pathname; + } catch { + return null; + } + } + try { + sourcePath = decodeURIComponent(sourcePath); + } catch { + // A literal percent character is valid in a filename. + } + const basename = sourcePath.split(/[\\/]/).at(-1) ?? ""; + const extensionIndex = basename.lastIndexOf("."); + return extensionIndex < 0 ? null : mediaMimeTypeFromExtension(basename.slice(extensionIndex)); +} + +export function mediaKindFromPath(path: string): "image" | "video" | null { + const mimeType = mediaMimeType(path); + if (mimeType === null) return null; + return mimeType.startsWith("video/") ? "video" : "image"; +} + function hasPreviewExtension(path: string, extensions: ReadonlyArray): boolean { const pathWithoutQuery = path.split(/[?#]/, 1)[0]?.toLowerCase() ?? ""; return extensions.some((extension) => pathWithoutQuery.endsWith(extension)); @@ -24,6 +77,11 @@ export function isWorkspaceImagePreviewPath(path: string): boolean { return hasPreviewExtension(path, WORKSPACE_IMAGE_PREVIEW_EXTENSIONS); } +/** File viewers receive literal filesystem paths, not Markdown URLs. */ +export function isWorkspaceVideoPreviewPath(path: string): boolean { + return videoMimeType({ name: path, mimeType: "" }) !== null; +} + export function isWorkspacePreviewEntryPath(path: string): boolean { return isWorkspaceBrowserPreviewPath(path) || isWorkspaceImagePreviewPath(path); } diff --git a/packages/shared/src/keybindings.ts b/packages/shared/src/keybindings.ts index 158a9ffb1ac9..939e88b7f0b6 100644 --- a/packages/shared/src/keybindings.ts +++ b/packages/shared/src/keybindings.ts @@ -46,6 +46,9 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ { key: "mod+o", command: "editor.openFavorite" }, { key: "mod+shift+[", command: "thread.previous" }, { key: "mod+shift+]", command: "thread.next" }, + { key: "mod+shift+c", command: "thread.copyReference", when: "!terminalFocus" }, + { key: "mod+shift+s", command: "thread.settle", when: "!terminalFocus" }, + { key: "mod+shift+p", command: "thread.pin", when: "!terminalFocus" }, ...THREAD_JUMP_KEYBINDING_COMMANDS.map((command, index) => ({ key: `mod+${index + 1}`, command, diff --git a/packages/shared/src/model.test.ts b/packages/shared/src/model.test.ts index b67c45744734..a65a60fa4cc7 100644 --- a/packages/shared/src/model.test.ts +++ b/packages/shared/src/model.test.ts @@ -1,7 +1,8 @@ import { describe, expect, it } from "vite-plus/test"; -import { ProviderDriverKind, ProviderInstanceId, type ModelCapabilities } from "@t3tools/contracts"; +import { ProviderInstanceId, type ModelCapabilities } from "@t3tools/contracts"; import { + applyClaudePromptEffortPrefix, buildProviderOptionSelectionsFromDescriptors, createModelCapabilities, createModelSelection, @@ -10,8 +11,6 @@ import { getProviderOptionDescriptors, getProviderOptionBooleanSelectionValue, getProviderOptionStringSelectionValue, - normalizeCustomModelSlug, - normalizeModelSlug, } from "./model.ts"; const codexCaps: ModelCapabilities = createModelCapabilities({ @@ -147,11 +146,32 @@ describe("descriptor helpers", () => { }); }); -describe("model slug normalization", () => { - it("preserves exact custom slugs instead of expanding provider aliases", () => { - const claude = ProviderDriverKind.make("claudeAgent"); +describe("applyClaudePromptEffortPrefix", () => { + it("keeps slash commands intact when ultrathink is selected", () => { + expect(applyClaudePromptEffortPrefix("/compact", "ultrathink")).toBe("/compact"); + expect(applyClaudePromptEffortPrefix(" /compact keep recent errors ", "ultrathink")).toBe( + "/compact keep recent errors", + ); + expect(applyClaudePromptEffortPrefix(" /review src/model.ts ", "ultrathink")).toBe( + "/review src/model.ts", + ); + expect(applyClaudePromptEffortPrefix("/security-review", "ultrathink")).toBe( + "/security-review", + ); + expect(applyClaudePromptEffortPrefix("/plugin:skill run", "ultrathink")).toBe( + "/plugin:skill run", + ); + expect(applyClaudePromptEffortPrefix("/deploy.prod to staging", "ultrathink")).toBe( + "/deploy.prod to staging", + ); + }); - expect(normalizeModelSlug("opus", claude)).toBe("claude-opus-5"); - expect(normalizeCustomModelSlug(" opus ")).toBe("opus"); + it("still adds the ultrathink prefix to ordinary prompts", () => { + expect(applyClaudePromptEffortPrefix("Investigate this failure", "ultrathink")).toBe( + "Ultrathink:\nInvestigate this failure", + ); + expect(applyClaudePromptEffortPrefix("/home/theo/app.ts crashed on load", "ultrathink")).toBe( + "Ultrathink:\n/home/theo/app.ts crashed on load", + ); }); }); diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts index bdc0c0cc8efb..1e47576949ed 100644 --- a/packages/shared/src/model.ts +++ b/packages/shared/src/model.ts @@ -1,6 +1,4 @@ import { - DEFAULT_MODEL, - DEFAULT_MODEL_BY_PROVIDER, MODEL_SLUG_ALIASES_BY_PROVIDER, type ModelCapabilities, type ModelSelection, @@ -15,6 +13,7 @@ const DEFAULT_PROVIDER_DRIVER_KIND = ProviderDriverKind.make("codex"); export interface SelectableModelOption { slug: string; name: string; + aliases?: ReadonlyArray | undefined; } export function createModelCapabilities(input: { @@ -56,13 +55,6 @@ export function getProviderOptionBooleanSelectionValue( return typeof value === "boolean" ? value : undefined; } -export function getModelSelectionOptionValue( - modelSelection: ModelSelection | null | undefined, - id: string, -): string | boolean | undefined { - return getProviderOptionSelectionValue(modelSelection?.options, id); -} - export function getModelSelectionStringOptionValue( modelSelection: ModelSelection | null | undefined, id: string, @@ -212,22 +204,6 @@ export function buildProviderOptionSelectionsFromDescriptors( return nextSelections.length > 0 ? nextSelections : undefined; } -export function getModelSelectionOptionDescriptors( - modelSelection: ModelSelection | null | undefined, - caps?: ModelCapabilities | null | undefined, -): ReadonlyArray { - if (!modelSelection) { - return []; - } - if (!caps) { - return []; - } - return getProviderOptionDescriptors({ - caps, - selections: modelSelection.options, - }); -} - export function isClaudeUltrathinkPrompt(text: string | null | undefined): boolean { return typeof text === "string" && /\bultrathink\b/i.test(text); } @@ -281,6 +257,13 @@ export function resolveSelectableModel( return byName.slug; } + const byAlias = options.find((option) => + option.aliases?.some((alias) => alias.toLowerCase() === trimmed.toLowerCase()), + ); + if (byAlias) { + return byAlias.slug; + } + const normalized = normalizeModelSlug(trimmed, provider); if (!normalized) { return null; @@ -290,21 +273,6 @@ export function resolveSelectableModel( return resolved ? resolved.slug : null; } -function resolveModelSlug(model: string | null | undefined, provider: ProviderDriverKind): string { - const normalized = normalizeModelSlug(model, provider); - if (!normalized) { - return DEFAULT_MODEL_BY_PROVIDER[provider] ?? DEFAULT_MODEL; - } - return normalized; -} - -export function resolveModelSlugForProvider( - provider: ProviderDriverKind, - model: string | null | undefined, -): string { - return resolveModelSlug(model, provider); -} - /** Trim a string, returning null for empty/missing values. */ export function trimOrNull(value: T | null | undefined): T | null { if (typeof value !== "string") return null; @@ -362,7 +330,11 @@ export function applyClaudePromptEffortPrefix( if (!trimmed) { return trimmed; } - if (effort !== "ultrathink") { + // Prefixing a slash command turns it into plain prose, so Claude never + // runs it. Command names come from arbitrary file names ("/deploy.prod", + // "/plugin:skill"), so accept any first token without a second slash; + // absolute paths like "/home/theo/app.ts" keep the prefix. + if (effort !== "ultrathink" || /^\/[^\s/]+(?:\s|$)/u.test(trimmed)) { return trimmed; } if (trimmed.startsWith("Ultrathink:")) { diff --git a/packages/shared/src/schemaJson.ts b/packages/shared/src/schemaJson.ts index 3307eb460f8e..e132b7084b74 100644 --- a/packages/shared/src/schemaJson.ts +++ b/packages/shared/src/schemaJson.ts @@ -119,19 +119,6 @@ export const decodeJsonResult = >( - schema: S, -) => { - const decode = Schema.decodeUnknownExit(Schema.fromJsonString(schema)); - return (input: unknown) => { - const result = decode(input); - if (Exit.isFailure(result)) { - return Result.fail(result.cause); - } - return Result.succeed(result.value); - }; -}; - export const formatSchemaError = (cause: Cause.Cause) => { const issues: Array = []; let issueCount = 0; diff --git a/packages/shared/src/shell.test.ts b/packages/shared/src/shell.test.ts index 89ec055f599e..e3046c03abed 100644 --- a/packages/shared/src/shell.test.ts +++ b/packages/shared/src/shell.test.ts @@ -304,7 +304,7 @@ describe("readEnvironmentFromWindowsShell", () => { }); describe("mergePathValues", () => { - it("dedupes case-insensitively on Windows while preserving preferred order", () => { + it("sanitizes and dedupes Windows entries while preserving preferred order", () => { expect( mergePathValues( 'C:\\Users\\testuser\\AppData\\Roaming\\npm;"C:\\Program Files\\nodejs"', @@ -312,10 +312,20 @@ describe("mergePathValues", () => { "win32", ), ).toBe( - 'C:\\Users\\testuser\\AppData\\Roaming\\npm;"C:\\Program Files\\nodejs";C:\\Windows\\System32', + "C:\\Users\\testuser\\AppData\\Roaming\\npm;C:\\Program Files\\nodejs;C:\\Windows\\System32", ); }); + it("removes stray quotes from Windows entries", () => { + expect( + mergePathValues( + 'C:\\Windows\\System32;C:\\cloudflared.exe;C:";C:\\Program Files\\nodejs', + undefined, + "win32", + ), + ).toBe("C:\\Windows\\System32;C:\\cloudflared.exe;C:;C:\\Program Files\\nodejs"); + }); + it("dedupes case-sensitively on POSIX", () => { expect(mergePathValues("/usr/local/bin:/usr/bin", "/usr/bin:/USR/BIN", "linux")).toBe( "/usr/local/bin:/usr/bin:/USR/BIN", @@ -450,7 +460,7 @@ effectIt.layer(NodeServices.layer)("resolveSpawnCommand", (it) => { }); effectIt.layer(NodeServices.layer)("resolveWindowsEnvironment", (it) => { - it.effect("returns the baseline no-profile PATH patch when node is already available", () => + it.effect("uses known CLI directories as a fallback without changing shell PATH priority", () => Effect.gen(function* () { const readEnvironment = vi.fn( (_names: ReadonlyArray, options?: { loadProfile?: boolean }) => @@ -473,6 +483,8 @@ effectIt.layer(NodeServices.layer)("resolveWindowsEnvironment", (it) => { ), ).toEqual({ PATH: [ + "C:\\Shell\\Bin", + "C:\\Windows\\System32", "C:\\Users\\testuser\\AppData\\Roaming\\npm", "C:\\Users\\testuser\\AppData\\Local\\Programs\\nodejs", "C:\\Users\\testuser\\AppData\\Local\\Volta\\bin", @@ -480,8 +492,6 @@ effectIt.layer(NodeServices.layer)("resolveWindowsEnvironment", (it) => { "C:\\Users\\testuser\\.local\\bin", "C:\\Users\\testuser\\.bun\\bin", "C:\\Users\\testuser\\scoop\\shims", - "C:\\Shell\\Bin", - "C:\\Windows\\System32", ].join(";"), }); expect(readEnvironment).toHaveBeenCalledTimes(1); @@ -522,6 +532,7 @@ effectIt.layer(NodeServices.layer)("resolveWindowsEnvironment", (it) => { PATH: [ "C:\\Profile\\Node", "C:\\Windows\\System32", + "C:\\Shell\\Bin", "C:\\Users\\testuser\\AppData\\Roaming\\npm", "C:\\Users\\testuser\\AppData\\Local\\Programs\\nodejs", "C:\\Users\\testuser\\AppData\\Local\\Volta\\bin", @@ -529,7 +540,6 @@ effectIt.layer(NodeServices.layer)("resolveWindowsEnvironment", (it) => { "C:\\Users\\testuser\\.local\\bin", "C:\\Users\\testuser\\.bun\\bin", "C:\\Users\\testuser\\scoop\\shims", - "C:\\Shell\\Bin", ].join(";"), FNM_DIR: "C:\\Users\\testuser\\AppData\\Roaming\\fnm", FNM_MULTISHELL_PATH: "C:\\Users\\testuser\\AppData\\Local\\fnm_multishells\\123", @@ -566,11 +576,11 @@ effectIt.layer(NodeServices.layer)("resolveWindowsEnvironment", (it) => { ), ).toEqual({ PATH: [ + "C:\\Windows\\System32", "C:\\Users\\testuser\\AppData\\Roaming\\npm", "C:\\Users\\testuser\\.local\\bin", "C:\\Users\\testuser\\.bun\\bin", "C:\\Users\\testuser\\scoop\\shims", - "C:\\Windows\\System32", ].join(";"), FNM_DIR: "C:\\Users\\testuser\\AppData\\Roaming\\fnm", }); diff --git a/packages/shared/src/shell.ts b/packages/shared/src/shell.ts index da6db2166765..4c86c8886312 100644 --- a/packages/shared/src/shell.ts +++ b/packages/shared/src/shell.ts @@ -414,6 +414,10 @@ function normalizePathEntryForComparison(entry: string, platform: NodeJS.Platfor return platform === "win32" ? normalized.toLowerCase() : normalized; } +function sanitizePathEntry(entry: string, platform: NodeJS.Platform): string { + return platform === "win32" ? entry.replaceAll('"', "") : entry; +} + export function mergePathValues( preferredPath: string | undefined, inheritedPath: string | undefined, @@ -427,14 +431,14 @@ export function mergePathValues( if (!rawValue) continue; for (const entry of rawValue.split(delimiter)) { - const trimmed = entry.trim(); - if (trimmed.length === 0) continue; + const sanitized = sanitizePathEntry(entry.trim(), platform); + if (sanitized.length === 0) continue; - const normalized = normalizePathEntryForComparison(trimmed, platform); + const normalized = normalizePathEntryForComparison(sanitized, platform); if (normalized.length === 0 || seen.has(normalized)) continue; seen.add(normalized); - merged.push(trimmed); + merged.push(sanitized); } } @@ -724,7 +728,9 @@ export const resolveWindowsEnvironment = Effect.fn("shell.resolveWindowsEnvironm }).PATH; const mergedPath = mergePathValues(shellPath, inheritedPath, "win32"); const knownCliPath = resolveKnownWindowsCliDirs(env).join(WINDOWS_PATH_DELIMITER); - const baselinePath = mergePathValues(knownCliPath, mergedPath, "win32"); + // Preserve the order a user's shell uses. These directories fill gaps when + // desktop apps launch without the full interactive-shell PATH. + const baselinePath = mergePathValues(mergedPath, knownCliPath, "win32"); const baselinePatch: Partial = baselinePath ? { PATH: baselinePath } : {}; const baselineEnv = mergeWindowsEnv(env, baselinePatch); diff --git a/packages/shared/src/themePalettes.ts b/packages/shared/src/themePalettes.ts index 919a3bfaf3d0..73b73a4de91e 100644 --- a/packages/shared/src/themePalettes.ts +++ b/packages/shared/src/themePalettes.ts @@ -10,6 +10,35 @@ export const MOBILE_DEFAULT_THEME_ID = "t3-code"; */ export const MOBILE_THEME_IDS = [MOBILE_DEFAULT_THEME_ID, ...BUILT_IN_THEME_IDS] as const; +/** + * Ids a theme may not take: the appearance keywords a stored preference uses, + * every built-in, and the legacy aliases older saves still carry. Taking one + * would either be shadowed by the built-in or capture clients that never chose + * it, so the client library and the publish path both consult this set. + */ +export const RESERVED_THEME_IDS: ReadonlySet = new Set([ + "system", + "light", + "dark", + ...BUILT_IN_THEME_IDS, + "t3-chat-dark", + "t3-grove", + "t3-ocean", + "t3-ember", + "t3-iris", +]); + +/** + * Additionally closed to a machine publishing a theme: the mobile default is + * not a web or desktop built-in, so a saved theme may legitimately carry that + * id, but no client that follows published themes can resolve it -- publishing + * it would report success and change nothing. + */ +export const UNPUBLISHABLE_THEME_IDS: ReadonlySet = new Set([ + ...RESERVED_THEME_IDS, + MOBILE_DEFAULT_THEME_ID, +]); + export type BuiltInThemeId = (typeof BUILT_IN_THEME_IDS)[number]; export type MobileThemeId = (typeof MOBILE_THEME_IDS)[number]; export type ThemeAppearance = "light" | "dark"; @@ -735,10 +764,6 @@ export const BUILT_IN_THEMES: ReadonlyArray = [ IRIS_THEME, ]; -export function getBuiltInTheme(id: string): ThemeDefinition | null { - return BUILT_IN_THEMES.find((theme) => theme.id === id) ?? null; -} - export function getThemeColorsForAppearance( theme: ThemeDefinition, appearance: ThemeAppearance, diff --git a/packages/shared/src/themePreview.test.ts b/packages/shared/src/themePreview.test.ts deleted file mode 100644 index f1cc02e10f7a..000000000000 --- a/packages/shared/src/themePreview.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { describe, expect, it } from "@effect/vitest"; - -import { - mixThemePreviewBase, - STANDARD_THEME_PREVIEW_COLORS, - THEME_PREVIEW_RENDER_SPECS, -} from "./themePreview.js"; - -describe("theme preview", () => { - it("keeps the desktop preview geometry stable across clients", () => { - expect(THEME_PREVIEW_RENDER_SPECS.light!.accent.center).toEqual([0.72, 0.22]); - expect(THEME_PREVIEW_RENDER_SPECS.dark!.accent.middleOpacity).toBe(0.62); - expect(THEME_PREVIEW_RENDER_SPECS.dark!.action.center).toEqual([0.82, 0.18]); - }); - - it("mixes the standard canvas bases in OKLab", () => { - expect(mixThemePreviewBase(STANDARD_THEME_PREVIEW_COLORS.light!, "light")).toBe("#fdfdfd"); - expect(mixThemePreviewBase(STANDARD_THEME_PREVIEW_COLORS.dark!, "dark")).toBe("#0a0a0a"); - }); -}); diff --git a/packages/shared/src/threadReference.test.ts b/packages/shared/src/threadReference.test.ts new file mode 100644 index 000000000000..693fabc66b16 --- /dev/null +++ b/packages/shared/src/threadReference.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { resolveThreadReferenceCopyTarget } from "./threadReference.ts"; + +describe("resolveThreadReferenceCopyTarget", () => { + it("prefers a durable linked pull request", () => { + expect( + resolveThreadReferenceCopyTarget({ + threadId: "thread-1", + linkedPullRequestUrl: "https://github.com/t3/pr/12", + detectedPullRequestUrl: "https://github.com/t3/pr/13", + }), + ).toMatchObject({ + kind: "pull-request", + value: "https://github.com/t3/pr/12", + successTitle: "PR link copied", + }); + }); + + it("uses a pull request detected from the active branch", () => { + expect( + resolveThreadReferenceCopyTarget({ + threadId: "thread-1", + detectedPullRequestUrl: "https://github.com/t3/pr/13", + }), + ).toMatchObject({ + kind: "pull-request", + value: "https://github.com/t3/pr/13", + }); + }); + + it("falls back to the thread ID", () => { + expect(resolveThreadReferenceCopyTarget({ threadId: "thread-1" })).toEqual({ + kind: "thread", + value: "thread-1", + clipboardTarget: "thread ID", + successTitle: "Thread ID copied", + failureTitle: "Failed to copy thread ID", + }); + }); +}); diff --git a/packages/shared/src/threadReference.ts b/packages/shared/src/threadReference.ts new file mode 100644 index 000000000000..cb4fc9dd5c20 --- /dev/null +++ b/packages/shared/src/threadReference.ts @@ -0,0 +1,30 @@ +export interface ThreadReferenceCopyTarget { + readonly kind: "pull-request" | "thread"; + readonly value: string; + readonly clipboardTarget: string; + readonly successTitle: string; + readonly failureTitle: string; +} + +export function resolveThreadReferenceCopyTarget(input: { + readonly threadId: string; + readonly linkedPullRequestUrl?: string | null; + readonly detectedPullRequestUrl?: string | null; +}): ThreadReferenceCopyTarget { + const pullRequestUrl = input.linkedPullRequestUrl ?? input.detectedPullRequestUrl; + return pullRequestUrl + ? { + kind: "pull-request", + value: pullRequestUrl, + clipboardTarget: "pull request link", + successTitle: "PR link copied", + failureTitle: "Failed to copy PR link", + } + : { + kind: "thread", + value: input.threadId, + clipboardTarget: "thread ID", + successTitle: "Thread ID copied", + failureTitle: "Failed to copy thread ID", + }; +} diff --git a/packages/shared/src/usageMerge.test.ts b/packages/shared/src/usageMerge.test.ts index 3bee4a9bdc02..6c706395c6ff 100644 --- a/packages/shared/src/usageMerge.test.ts +++ b/packages/shared/src/usageMerge.test.ts @@ -158,7 +158,7 @@ describe("mergeUsage", () => { summary( [bucket()], [{ provider: "claude", hostId: "linux", homePath: "/b" }], - USAGE_CONTRACT_VERSION - 1, + USAGE_CONTRACT_VERSION - 2, ), ), ], @@ -169,6 +169,32 @@ describe("mergeUsage", () => { expect(merged.staleEnvironments).toEqual(["env-b"]); }); + it("keeps the previous compatible contract version so additive provider expansions still merge", () => { + const merged = mergeUsage( + [ + environment( + "env-a", + summary( + [bucket({ costUsd: 10 })], + [{ provider: "claude", hostId: "mac", homePath: "/a" }], + ), + ), + environment( + "env-b", + summary( + [bucket({ costUsd: 4, provider: "codex", model: "gpt-5.6-sol" })], + [{ provider: "codex", hostId: "linux", homePath: "/b" }], + USAGE_CONTRACT_VERSION - 1, + ), + ), + ], + USAGE_CONTRACT_VERSION, + ); + + expect(merged.costUsd).toBe(14); + expect(merged.staleEnvironments).toEqual([]); + }); + it("derives provider shares and cost quality", () => { const merged = mergeUsage( [ diff --git a/packages/shared/src/usageMerge.ts b/packages/shared/src/usageMerge.ts index 954139b4e10f..428599d51c74 100644 --- a/packages/shared/src/usageMerge.ts +++ b/packages/shared/src/usageMerge.ts @@ -6,12 +6,13 @@ * * @module usageMerge */ -import type { - EnvironmentId, - UsageBucket, - UsageProviderKind, - UsageSourceFingerprint, - UsageSummary, +import { + USAGE_MERGE_COMPATIBLE_SINCE, + type EnvironmentId, + type UsageBucket, + type UsageProviderKind, + type UsageSourceFingerprint, + type UsageSummary, } from "@t3tools/contracts"; export interface EnvironmentUsage { @@ -172,6 +173,10 @@ function bucketTokens(bucket: UsageBucket): number { ); } +function isCompatibleContractVersion(version: number, expected: number): boolean { + return version >= USAGE_MERGE_COMPATIBLE_SINCE && version <= expected; +} + const EMPTY_MERGED: MergedUsage = { costUsd: 0, uncachedInputTokens: 0, @@ -201,8 +206,10 @@ const EMPTY_MERGED: MergedUsage = { * Merges every connected environment's summary. * * `expectedContractVersion` guards against an environment running older server - * code: rather than blocking the page, its data is excluded and its id is - * reported so the UI can say coverage is partial. + * code: rather than blocking the page, incompatible data is excluded and its + * id is reported so the UI can say coverage is partial. Versions in + * [{@link USAGE_MERGE_COMPATIBLE_SINCE}, expected] still merge, so an additive + * provider expansion does not drop Claude/Codex totals from older servers. */ export function mergeUsage( environments: readonly EnvironmentUsage[], @@ -213,7 +220,7 @@ export function mergeUsage( const current: EnvironmentUsage[] = []; const staleEnvironments: EnvironmentId[] = []; for (const environment of environments) { - if (environment.summary.contractVersion === expectedContractVersion) { + if (isCompatibleContractVersion(environment.summary.contractVersion, expectedContractVersion)) { current.push(environment); } else { staleEnvironments.push(environment.environmentId); diff --git a/packages/shared/src/video.test.ts b/packages/shared/src/video.test.ts new file mode 100644 index 000000000000..b550033e2987 --- /dev/null +++ b/packages/shared/src/video.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { videoMimeType } from "./video.ts"; + +describe("videoMimeType", () => { + it("recognizes a saved video with a generic picker MIME type", () => { + expect(videoMimeType({ name: "Recording.MOV", mimeType: "application/octet-stream" })).toBe( + "video/quicktime", + ); + }); + + it("keeps an explicit video MIME type authoritative and removes parameters", () => { + expect(videoMimeType({ name: "recording.mp4", mimeType: " VIDEO/WebM; codecs=vp9 " })).toBe( + "video/webm", + ); + }); + + it.each(["README", "report.pdf", "file.constructor", "file.__proto__"])( + "does not mistake %s for a video", + (name) => { + expect(videoMimeType({ name, mimeType: "application/octet-stream" })).toBeNull(); + }, + ); +}); diff --git a/packages/shared/src/video.ts b/packages/shared/src/video.ts new file mode 100644 index 000000000000..291674c705ca --- /dev/null +++ b/packages/shared/src/video.ts @@ -0,0 +1,24 @@ +const VIDEO_MIME_TYPE_BY_EXTENSION = new Map([ + ["avi", "video/x-msvideo"], + ["m4v", "video/mp4"], + ["mkv", "video/x-matroska"], + ["mov", "video/quicktime"], + ["mp4", "video/mp4"], + ["ogv", "video/ogg"], + ["webm", "video/webm"], +]); + +export const VIDEO_FILE_EXTENSIONS = Object.freeze([...VIDEO_MIME_TYPE_BY_EXTENSION.keys()]); + +/** Recognizes videos even when the file picker omitted their MIME type. */ +export function videoMimeType(attachment: { + readonly name: string; + readonly mimeType: string; +}): string | null { + const mimeType = attachment.mimeType.split(";", 1)[0]?.trim().toLowerCase() ?? ""; + if (mimeType.startsWith("video/")) return mimeType; + const dotIndex = attachment.name.lastIndexOf("."); + return dotIndex < 0 + ? null + : (VIDEO_MIME_TYPE_BY_EXTENSION.get(attachment.name.slice(dotIndex + 1).toLowerCase()) ?? null); +} diff --git a/packages/tailscale/package.json b/packages/tailscale/package.json index ce020dc8ef56..306ec104ab54 100644 --- a/packages/tailscale/package.json +++ b/packages/tailscale/package.json @@ -13,7 +13,6 @@ "test": "vp test run" }, "dependencies": { - "@effect/platform-node": "catalog:", "@t3tools/shared": "workspace:*", "effect": "catalog:" }, diff --git a/packages/tailscale/src/tailscale.ts b/packages/tailscale/src/tailscale.ts index fedde02ee76f..d6db5e8bcc59 100644 --- a/packages/tailscale/src/tailscale.ts +++ b/packages/tailscale/src/tailscale.ts @@ -382,23 +382,3 @@ export const probeTailscaleHttpsEndpoint = (input: { onSome: (httpResponse) => httpResponse.status >= 200 && httpResponse.status < 300, }); }).pipe(Effect.orElseSucceed(() => false)); - -export const resolveTailscaleHttpsBaseUrl = ( - input: { - readonly servePort?: number; - } = {}, -): Effect.Effect< - string | null, - TailscaleCommandError | TailscaleStatusParseError, - ChildProcessSpawner.ChildProcessSpawner -> => - readTailscaleStatus.pipe( - Effect.map((status) => - status.magicDnsName - ? buildTailscaleHttpsBaseUrl({ - magicDnsName: status.magicDnsName, - ...(input.servePort === undefined ? {} : { servePort: input.servePort }), - }) - : null, - ), - ); diff --git a/patches/@expo%2Fmetro-config@56.0.14.patch b/patches/@expo__metro-config@57.0.12.patch similarity index 74% rename from patches/@expo%2Fmetro-config@56.0.14.patch rename to patches/@expo__metro-config@57.0.12.patch index c25173730c82..26ce34aed6dd 100644 --- a/patches/@expo%2Fmetro-config@56.0.14.patch +++ b/patches/@expo__metro-config@57.0.12.patch @@ -1,18 +1,11 @@ diff --git a/build/serializer/sourceMap.js b/build/serializer/sourceMap.js -index 4cc9aa4..703dfe0 100644 +index 547e53377a7c560db8dfd983601606de15896e07..29a0c855088bceacb7ba00a627615539cbf37738 100644 --- a/build/serializer/sourceMap.js +++ b/build/serializer/sourceMap.js -@@ -20,6 +20,20 @@ function loadRemapping() { +@@ -27,6 +27,13 @@ function loadSourcemapCodec() { } - return _remapping; + return _sourcemapCodec; } -+let _sourceMapCodec; -+function loadSourceMapCodec() { -+ if (!_sourceMapCodec) { -+ _sourceMapCodec = require('@jridgewell/sourcemap-codec'); -+ } -+ return _sourceMapCodec; -+} +let _traceMapping; +function loadTraceMapping() { + if (!_traceMapping) { @@ -23,9 +16,9 @@ index 4cc9aa4..703dfe0 100644 let _Generator; function loadGenerator() { if (!_Generator) { -@@ -199,6 +213,58 @@ function patchMetroSourceMapStringForPackedMaps() { - stock.sourceMapString = sourceMapString; - stock.sourceMapStringNonBlocking = sourceMapStringNonBlocking; +@@ -224,6 +231,58 @@ function repairInvalidNegativeIndices(map) { + } + return changed ? { ...map, mappings: encode(decoded) } : map; } +// Hermes can emit mappings for Metro trailer/debug lines that have no +// corresponding Metro source-map line. @jridgewell/remapping assumes every @@ -62,7 +55,7 @@ index 4cc9aa4..703dfe0 100644 + } + return { + ...map, -+ mappings: loadSourceMapCodec().encode(filtered), ++ mappings: loadSourcemapCodec().encode(filtered), + }; +} +function sanitizeSourceMapsForComposition(maps) { @@ -82,12 +75,12 @@ index 4cc9aa4..703dfe0 100644 // `maps[0]` is the original-most transform; `maps[maps.length - 1]` is // the most recent. Built on `@jridgewell/remapping` instead of mozilla's // `SourceMapConsumer`-based composer. -@@ -226,7 +292,7 @@ function composeSourceMaps(maps) { +@@ -251,7 +310,7 @@ function composeSourceMaps(maps) { return { ...map, ignoreList: map.x_google_ignoreList }; }); // Metro convention is original-first; remapping is most-recent first. -- const reversed = normalized.slice().reverse(); -+ const reversed = sanitizeSourceMapsForComposition(normalized).slice().reverse(); - const composed = loadRemapping()(reversed, () => null); - // Re-emit as a plain object — remapping returns a `SourceMap` class - // instance, which doesn't round-trip JSON cleanly. +- let input = normalized.slice().reverse(); ++ let input = sanitizeSourceMapsForComposition(normalized).slice().reverse(); + const remap = loadRemapping(); + let composed; + try { diff --git a/patches/@legendapp__list@3.3.5.patch b/patches/@legendapp__list@3.3.5.patch index 5b45f8592a1a..2fea500e1bde 100644 --- a/patches/@legendapp__list@3.3.5.patch +++ b/patches/@legendapp__list@3.3.5.patch @@ -1,5 +1,5 @@ diff --git a/keyboard.d.ts b/keyboard.d.ts -index 367945cdfa8a8c260b7a127657a75c016c9ab46f..95263268a47d3f1f57bbc5d528bcc77674f9121f 100644 +index 367945cdfa8a8c260b7a127657a75c016c9ab46f..0ac7bf8b8e9c386058b2374199e58fe8c92d39b3 100644 --- a/keyboard.d.ts +++ b/keyboard.d.ts @@ -279,7 +279,7 @@ type KeyboardChatComposerInsetListRef = { @@ -7,7 +7,7 @@ index 367945cdfa8a8c260b7a127657a75c016c9ab46f..95263268a47d3f1f57bbc5d528bcc776 current: Pick | null; }; -declare function useKeyboardChatComposerInset(listRef: KeyboardChatComposerInsetListRef, composerRef: KeyboardChatComposerRef, initialHeight?: number): { -+declare function useKeyboardChatComposerInset(listRef: KeyboardChatComposerInsetListRef, composerRef: KeyboardChatComposerRef, initialHeight?: number, heightAdjustment?: number): { ++declare function useKeyboardChatComposerInset(listRef: KeyboardChatComposerInsetListRef, composerRef: KeyboardChatComposerRef, initialHeight?: number, heightAdjustment?: number, animationDuration?: number): { contentInsetEndAdjustment: SharedValue; onComposerLayout: (event: LayoutChangeEvent) => void; }; @@ -23,15 +23,15 @@ index 367945cdfa8a8c260b7a127657a75c016c9ab46f..95263268a47d3f1f57bbc5d528bcc776 } & React.RefAttributes) => React.ReactElement | null; diff --git a/keyboard.js b/keyboard.js -index 6645bcbb1f77c36c432035eaf8b2d6d924fc8bda..74c1f568c485d0f907a0d3ea4ad600ccb8d3be62 100644 +index 6645bcbb1f77c36c432035eaf8b2d6d924fc8bda..321c4c305129dd3ce3f06127c47d51720a73fb83 100644 --- a/keyboard.js +++ b/keyboard.js -@@ -33,19 +33,19 @@ if (typeof __DEV__ !== "undefined" && __DEV__ && !reactNativeKeyboardController. +@@ -33,19 +33,22 @@ if (typeof __DEV__ !== "undefined" && __DEV__ && !reactNativeKeyboardController. "[legend-list] KeyboardAwareLegendList requires a recent react-native-keyboard-controller with KeyboardChatScrollView. Please upgrade react-native-keyboard-controller to at least 1.21.7." ); } -function useKeyboardChatComposerInset(listRef, composerRef, initialHeight = 0) { -+function useKeyboardChatComposerInset(listRef, composerRef, initialHeight = 0, heightAdjustment = 0) { ++function useKeyboardChatComposerInset(listRef, composerRef, initialHeight = 0, heightAdjustment = 0, animationDuration = 0) { const contentInsetEndAdjustment = reactNativeReanimated.useSharedValue(initialHeight); const lastHeightRef = React.useRef(void 0); const reportHeight = React.useCallback( @@ -40,17 +40,21 @@ index 6645bcbb1f77c36c432035eaf8b2d6d924fc8bda..74c1f568c485d0f907a0d3ea4ad600cc + const height = Math.max(0, rawHeight + heightAdjustment); var _a; if (Number.isFinite(height) && height !== lastHeightRef.current) { ++ const shouldAnimate = lastHeightRef.current !== void 0 && animationDuration > 0; lastHeightRef.current = height; - contentInsetEndAdjustment.value = height; +- contentInsetEndAdjustment.value = height; - (_a = listRef.current) == null ? void 0 : _a.reportContentInset({ bottom: height }); ++ contentInsetEndAdjustment.value = shouldAnimate ? reactNativeReanimated.withTiming(height, { ++ duration: animationDuration ++ }) : height; } }, - [contentInsetEndAdjustment, listRef] -+ [contentInsetEndAdjustment, heightAdjustment, listRef] ++ [animationDuration, contentInsetEndAdjustment, heightAdjustment, listRef] ); React.useLayoutEffect(() => { var _a; -@@ -84,9 +84,11 @@ function useKeyboardScrollToEnd({ freeze: freezeProp, listRef }) { +@@ -84,9 +87,11 @@ function useKeyboardScrollToEnd({ freeze: freezeProp, listRef }) { } var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(props, forwardedRef) { const { @@ -62,7 +66,15 @@ index 6645bcbb1f77c36c432035eaf8b2d6d924fc8bda..74c1f568c485d0f907a0d3ea4ad600cc freeze, keyboardLiftBehavior, keyboardOffset, -@@ -108,11 +110,15 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( +@@ -94,6 +99,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( + } = props; + const refLegendList = React.useRef(null); + const combinedRef = useCombinedRef(forwardedRef, refLegendList); ++ const adjustedStartInsetCompensation = props.contentInsetStartAdjustment; + const blankSpace = reactNativeReanimated.useSharedValue(0); + React.useEffect(() => { + if (!anchoredEndSpace) { +@@ -108,11 +114,15 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( ...anchoredEndSpace, onSizeChanged: (size) => { var _a; @@ -80,23 +92,25 @@ index 6645bcbb1f77c36c432035eaf8b2d6d924fc8bda..74c1f568c485d0f907a0d3ea4ad600cc const onContentInsetChange = React.useCallback((insets) => { var _a; (_a = refLegendList.current) == null ? void 0 : _a.reportContentInset(insets); -@@ -123,6 +129,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( +@@ -123,6 +133,8 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( reactNativeKeyboardController.KeyboardChatScrollView, { ...scrollProps, + adjustedInsetCompensation, ++ adjustedStartInsetCompensation, applyWorkaroundForContentInsetHitTestBug, blankSpace, extraContentPadding: contentInsetEndAdjustment, -@@ -134,6 +141,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( +@@ -134,6 +146,8 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( ); }, [ + adjustedInsetCompensation, ++ adjustedStartInsetCompensation, applyWorkaroundForContentInsetHitTestBug, blankSpace, contentInsetEndAdjustment, -@@ -149,6 +157,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( +@@ -149,6 +163,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( { anchoredEndSpace: anchoredEndSpaceWithBlankSpace, anchoredEndSpaceOwnerInternal: "scroll", @@ -105,15 +119,24 @@ index 6645bcbb1f77c36c432035eaf8b2d6d924fc8bda..74c1f568c485d0f907a0d3ea4ad600cc renderScrollComponent: memoList, ...rest diff --git a/keyboard.mjs b/keyboard.mjs -index 87b38b9607c2eba6b407c2acfd520633bdb0e7c2..62206d657d5616893985fd2c228e57af7eba744b 100644 +index 87b38b9607c2eba6b407c2acfd520633bdb0e7c2..111eb242ba9d9ade1cb912550f7df85cf4f361ee 100644 --- a/keyboard.mjs +++ b/keyboard.mjs -@@ -12,19 +12,19 @@ if (typeof __DEV__ !== "undefined" && __DEV__ && !KeyboardChatScrollView) { +@@ -1,7 +1,7 @@ + import * as React from 'react'; + import { useRef, useEffect, useMemo, useCallback, useLayoutEffect } from 'react'; + import { KeyboardChatScrollView, KeyboardController } from 'react-native-keyboard-controller'; +-import { useSharedValue } from 'react-native-reanimated'; ++import { useSharedValue, withTiming } from 'react-native-reanimated'; + import { internal } from '@legendapp/list/react-native'; + import { AnimatedLegendList } from '@legendapp/list/reanimated'; + +@@ -12,19 +12,22 @@ if (typeof __DEV__ !== "undefined" && __DEV__ && !KeyboardChatScrollView) { "[legend-list] KeyboardAwareLegendList requires a recent react-native-keyboard-controller with KeyboardChatScrollView. Please upgrade react-native-keyboard-controller to at least 1.21.7." ); } -function useKeyboardChatComposerInset(listRef, composerRef, initialHeight = 0) { -+function useKeyboardChatComposerInset(listRef, composerRef, initialHeight = 0, heightAdjustment = 0) { ++function useKeyboardChatComposerInset(listRef, composerRef, initialHeight = 0, heightAdjustment = 0, animationDuration = 0) { const contentInsetEndAdjustment = useSharedValue(initialHeight); const lastHeightRef = useRef(void 0); const reportHeight = useCallback( @@ -122,17 +145,21 @@ index 87b38b9607c2eba6b407c2acfd520633bdb0e7c2..62206d657d5616893985fd2c228e57af + const height = Math.max(0, rawHeight + heightAdjustment); var _a; if (Number.isFinite(height) && height !== lastHeightRef.current) { ++ const shouldAnimate = lastHeightRef.current !== void 0 && animationDuration > 0; lastHeightRef.current = height; - contentInsetEndAdjustment.value = height; +- contentInsetEndAdjustment.value = height; - (_a = listRef.current) == null ? void 0 : _a.reportContentInset({ bottom: height }); ++ contentInsetEndAdjustment.value = shouldAnimate ? withTiming(height, { ++ duration: animationDuration ++ }) : height; } }, - [contentInsetEndAdjustment, listRef] -+ [contentInsetEndAdjustment, heightAdjustment, listRef] ++ [animationDuration, contentInsetEndAdjustment, heightAdjustment, listRef] ); useLayoutEffect(() => { var _a; -@@ -63,9 +63,11 @@ function useKeyboardScrollToEnd({ freeze: freezeProp, listRef }) { +@@ -63,9 +66,11 @@ function useKeyboardScrollToEnd({ freeze: freezeProp, listRef }) { } var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(props, forwardedRef) { const { @@ -144,7 +171,15 @@ index 87b38b9607c2eba6b407c2acfd520633bdb0e7c2..62206d657d5616893985fd2c228e57af freeze, keyboardLiftBehavior, keyboardOffset, -@@ -87,11 +89,15 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( +@@ -73,6 +78,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( + } = props; + const refLegendList = useRef(null); + const combinedRef = useCombinedRef(forwardedRef, refLegendList); ++ const adjustedStartInsetCompensation = props.contentInsetStartAdjustment; + const blankSpace = useSharedValue(0); + useEffect(() => { + if (!anchoredEndSpace) { +@@ -87,11 +93,15 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( ...anchoredEndSpace, onSizeChanged: (size) => { var _a; @@ -162,23 +197,25 @@ index 87b38b9607c2eba6b407c2acfd520633bdb0e7c2..62206d657d5616893985fd2c228e57af const onContentInsetChange = useCallback((insets) => { var _a; (_a = refLegendList.current) == null ? void 0 : _a.reportContentInset(insets); -@@ -102,6 +108,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( +@@ -102,6 +112,8 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( KeyboardChatScrollView, { ...scrollProps, + adjustedInsetCompensation, ++ adjustedStartInsetCompensation, applyWorkaroundForContentInsetHitTestBug, blankSpace, extraContentPadding: contentInsetEndAdjustment, -@@ -113,6 +120,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( +@@ -113,6 +125,8 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( ); }, [ + adjustedInsetCompensation, ++ adjustedStartInsetCompensation, applyWorkaroundForContentInsetHitTestBug, blankSpace, contentInsetEndAdjustment, -@@ -128,6 +136,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( +@@ -128,6 +142,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( { anchoredEndSpace: anchoredEndSpaceWithBlankSpace, anchoredEndSpaceOwnerInternal: "scroll", @@ -204,10 +241,35 @@ index ce1fe00001c9e5aee6c6ea8bb2d4757d4586d002..3ccf6f16067152dfcb0c143371e2ec6a * Number of columns to render items in. * @default 1 diff --git a/react-native.js b/react-native.js -index b3c5a306b293f797a8b338adfca3060c0f6db22b..4227ed22da8865b7493f47f199f4d8f4a5493efb 100644 +index b3c5a306b293f797a8b338adfca3060c0f6db22b..24d0763aef074411eb7d17f0feb8df752a843de0 100644 --- a/react-native.js +++ b/react-native.js -@@ -954,7 +954,7 @@ function setInitialRenderState(ctx, { +@@ -717,6 +717,15 @@ function hasActiveInitialScroll(state) { + return !!(state == null ? void 0 : state.initialScroll) && !state.didFinishInitialScroll; + } + ++// Size-only changes may not emit a scroll event to refresh the edge signal. ++function getIsAtEnd(ctx, contentSize = getContentSize(ctx)) { ++ const { queuedInitialLayout, scroll, scrollLength } = ctx.state; ++ if (!(contentSize > 0 && queuedInitialLayout)) { ++ return peek$(ctx, "isAtEnd"); ++ } ++ return contentSize < scrollLength || contentSize - scroll - scrollLength - getContentInsetEnd(ctx) <= EDGE_POSITION_EPSILON; ++} ++ + // src/utils/checkAtBottom.ts + function checkAtBottom(ctx, allowedEdge, allowGateCreatedInCurrentCheck) { + var _a3; +@@ -737,7 +746,7 @@ function checkAtBottom(ctx, allowedEdge, allowGateCreatedInCurrentCheck) { + const insetEnd = getContentInsetEnd(ctx); + const distanceFromEnd = contentSize - scroll - scrollLength - insetEnd; + const isContentLess = contentSize < scrollLength; +- set$(ctx, "isAtEnd", isContentLess || distanceFromEnd <= EDGE_POSITION_EPSILON); ++ set$(ctx, "isAtEnd", getIsAtEnd(ctx, contentSize)); + set$(ctx, "isNearEnd", isContentLess || distanceFromEnd <= onEndReachedThreshold * scrollLength); + set$( + ctx, +@@ -954,7 +963,7 @@ function setInitialRenderState(ctx, { if (didInitialScroll) { state.didFinishInitialScroll = true; } @@ -216,7 +278,27 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..4227ed22da8865b7493f47f199f4d8f4 if (isReadyToRender && !peek$(ctx, "readyToRender")) { set$(ctx, "readyToRender", true); setAdaptiveRender(ctx, "normal", "ready"); -@@ -1304,18 +1304,23 @@ function calculateOffsetWithOffsetPosition(ctx, offsetParam, params) { +@@ -1090,7 +1099,7 @@ function getRawContentLength(ctx) { + function getAlignItemsAtEndPadding(ctx) { + const { state } = ctx; + const shouldPad = !!state.props.alignItemsAtEndPaddingEnabled && !state.props.horizontal && state.props.data.length > 0 && state.scrollLength > 0; +- return shouldPad ? Math.max(0, state.scrollLength - getRawContentLength(ctx) - getContentInsetEnd(ctx)) : 0; ++ return shouldPad ? Math.max(0, state.scrollLength - getContentInsetStartAdjustment(ctx) - getRawContentLength(ctx) - getContentInsetEnd(ctx)) : 0; + } + function updateContentMetricsState(ctx) { + const previousPadding = peek$(ctx, "alignItemsAtEndPadding") || 0; +@@ -1115,6 +1124,10 @@ function addTotalSize(ctx, key, add, notifyTotalSize = true) { + totalSize += add; + } + if (prevTotalSize !== totalSize) { ++ const now = Date.now(); ++ const isContinuingContentSizeAnimation = state.contentSizeAnimationActiveEpoch !== void 0; ++ state.contentSizeAnimationEpoch = (state.contentSizeAnimationEpoch || 0) + 1; ++ state.contentSizeAnimationEligible = !!state.props.sizeComponentInternal && state.didContainersLayout && (isContinuingContentSizeAnimation || !state.props.maintainScrollAtEnd && !state.isUserDragging && !state.isMomentumScrolling && now - (state.lastMVCPAdjustTime || 0) >= 300); + if (!IsNewArchitecture && state.initialScroll && totalSize < prevTotalSize) { + state.pendingTotalSize = totalSize; + } else { +@@ -1304,18 +1317,23 @@ function calculateOffsetWithOffsetPosition(ctx, offsetParam, params) { } // src/core/clampScrollOffset.ts @@ -242,7 +324,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..4227ed22da8865b7493f47f199f4d8f4 return clampedOffset; } -@@ -1451,10 +1456,10 @@ function checkFinishedScrollFrame(ctx) { +@@ -1451,10 +1469,10 @@ function checkFinishedScrollFrame(ctx) { finishScrollTo(ctx); } } @@ -255,7 +337,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..4227ed22da8865b7493f47f199f4d8f4 x: ctx.state.props.horizontal ? offset : 0, y: ctx.state.props.horizontal ? 0 : offset }); -@@ -1503,7 +1508,10 @@ function checkFinishedScrollFallback(ctx) { +@@ -1503,7 +1521,10 @@ function checkFinishedScrollFallback(ctx) { ); scheduleFallbackCheck(SILENT_INITIAL_SCROLL_RETRY_DELAY_MS); } else if (shouldRetryUnalignedEndScroll) { @@ -267,7 +349,17 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..4227ed22da8865b7493f47f199f4d8f4 scheduleFallbackCheck(100); } else if (shouldFinishZeroTarget || shouldFinishAfterObservedScroll || canFinishInitialScrollWithoutNativeProgress || canFinishAfterSilentNativeDispatch || numChecks > maxChecks) { finishScrollTo(ctx); -@@ -1566,9 +1574,18 @@ function doMaintainScrollAtEnd(ctx) { +@@ -1560,15 +1581,28 @@ function doMaintainScrollAtEnd(ctx) { + } = state; + const isWithinMaintainScrollAtEndThreshold = peek$(ctx, "isWithinMaintainScrollAtEndThreshold"); + const shouldMaintainScrollAtEnd = !!(isWithinMaintainScrollAtEndThreshold && maintainScrollAtEnd && didContainersLayout); ++ if (state.contentSizeAnimationActiveEpoch !== void 0) { ++ state.pendingMaintainScrollAtEnd = false; ++ return false; ++ } + if (pendingNativeMVCPAdjust) { + state.pendingMaintainScrollAtEnd = shouldMaintainScrollAtEnd; + return false; } if (shouldMaintainScrollAtEnd) { state.pendingMaintainScrollAtEnd = false; @@ -287,7 +379,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..4227ed22da8865b7493f47f199f4d8f4 } if (!state.maintainingScrollAtEnd) { const pendingState = maintainScrollAtEnd.animated ? "pending-animated" : "pending-instant"; -@@ -1591,9 +1608,18 @@ function doMaintainScrollAtEnd(ctx) { +@@ -1591,9 +1625,18 @@ function doMaintainScrollAtEnd(ctx) { y: 0 }); } else { @@ -309,7 +401,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..4227ed22da8865b7493f47f199f4d8f4 } setTimeout( () => { -@@ -1624,6 +1650,10 @@ function doMaintainScrollAtEnd(ctx) { +@@ -1624,6 +1667,10 @@ function doMaintainScrollAtEnd(ctx) { function requestAdjust(ctx, positionDiff, dataChanged) { const state = ctx.state; if (Math.abs(positionDiff) > 0.1) { @@ -320,7 +412,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..4227ed22da8865b7493f47f199f4d8f4 const needsScrollWorkaround = Platform.OS === "android" && !IsNewArchitecture && dataChanged && state.scroll <= positionDiff; const doit = () => { if (needsScrollWorkaround) { -@@ -1728,7 +1758,9 @@ function getPredictedNativeClamp(state, unresolvedAmount, totalSize) { +@@ -1728,7 +1775,9 @@ function getPredictedNativeClamp(state, unresolvedAmount, totalSize) { if (Math.abs(unresolvedAmount) <= MVCP_POSITION_EPSILON) { return 0; } @@ -331,7 +423,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..4227ed22da8865b7493f47f199f4d8f4 const clampDelta = maxScroll - state.scroll; if (unresolvedAmount < 0) { return Math.max(unresolvedAmount, Math.min(0, clampDelta)); -@@ -1790,7 +1822,7 @@ function resolvePendingNativeMVCPAdjust(ctx, newScroll) { +@@ -1790,7 +1839,7 @@ function resolvePendingNativeMVCPAdjust(ctx, newScroll) { settlePendingNativeMVCPAdjust(ctx, remainingAfterManual, nativeDelta); return true; } @@ -340,7 +432,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..4227ed22da8865b7493f47f199f4d8f4 const distanceToClamp = Math.abs(newScroll - expectedNativeClampScroll); const isAtExpectedNativeClamp = distanceToClamp <= NATIVE_END_CLAMP_EPSILON; if (isAtExpectedNativeClamp) { -@@ -1923,7 +1955,7 @@ function prepareMVCP(ctx, dataChanged) { +@@ -1923,7 +1972,7 @@ function prepareMVCP(ctx, dataChanged) { if (diff > 0) { diff = Math.max(0, totalSize - state.scroll - state.scrollLength); } else { @@ -349,7 +441,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..4227ed22da8865b7493f47f199f4d8f4 state.scroll = maxScroll; state.scrollPending = maxScroll; diff = 0; -@@ -2320,8 +2352,121 @@ function scrollToIndex(ctx, { +@@ -2320,8 +2369,121 @@ function scrollToIndex(ctx, { } // src/core/initialScroll.ts @@ -471,7 +563,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..4227ed22da8865b7493f47f199f4d8f4 const requestedIndex = target.index; const index = requestedIndex !== void 0 ? clampScrollIndex(requestedIndex, ctx.state.props.data.length) : void 0; const itemSize = getItemSizeAtIndex(ctx, index); -@@ -2747,7 +2892,9 @@ function clearFinishedBootstrapInitialScrollTargetIfMovedAway(ctx) { +@@ -2747,7 +2909,9 @@ function clearFinishedBootstrapInitialScrollTargetIfMovedAway(ctx) { return; } if (didFinishedInitialScrollMoveAwayFromTarget(ctx, initialScroll)) { @@ -482,7 +574,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..4227ed22da8865b7493f47f199f4d8f4 if (!shouldKeepEndTargetAlive) { if (shouldPreserveInitialScrollForFooterLayout(initialScroll)) { clearPendingInitialScrollFooterLayout(ctx, { -@@ -4672,7 +4819,8 @@ function maybeUpdateAnchoredEndSpace(ctx) { +@@ -4672,7 +4836,8 @@ function maybeUpdateAnchoredEndSpace(ctx) { contentBelowAnchor = Math.max(0, contentBelowAnchor - ctx.scrollAxisGap); contentBelowAnchor += (ctx.values.get("footerSize") || 0) + getStylePaddingEnd(state.props); isReady = !hasUnknownTailSize; @@ -492,7 +584,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..4227ed22da8865b7493f47f199f4d8f4 } else if (anchorIndex >= 0) { isReady = false; } -@@ -4692,6 +4840,12 @@ function maybeUpdateAnchoredEndSpace(ctx) { +@@ -4692,6 +4857,12 @@ function maybeUpdateAnchoredEndSpace(ctx) { updateScroll(ctx, state.scroll, true, { markHasScrolled: false }); } (_b = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onReady) == null ? void 0 : _b.call(anchoredEndSpace, { anchorIndex: nextAnchorIndex, anchorKey: nextAnchorKey, size: nextSize }); @@ -505,7 +597,93 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..4227ed22da8865b7493f47f199f4d8f4 } return nextSize; } -@@ -7075,6 +7229,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -5715,6 +5886,7 @@ var ContainersLayer = typedMemo(function ContainersLayer2({ + horizontal + }) { + const ctx = useStateContext(); ++ const SizeComponent = ctx.state.props.sizeComponentInternal; + const columnWrapperStyle = ctx.columnWrapperStyle; + const animSize = useValue$("totalSize"); + const [readyToRender, numColumns, otherAxisSize = 0] = useArr$(["readyToRender", "numColumns", "otherAxisSize"]); +@@ -5725,6 +5897,13 @@ var ContainersLayer = typedMemo(function ContainersLayer2({ + opacity: isVisible ? 1 : 0, + width: animSize + } : { height: animSize, minWidth: otherAxisSize, opacity: isVisible ? 1 : 0 }; ++ if (SizeComponent) { ++ if (horizontal) { ++ delete style.width; ++ } else { ++ delete style.height; ++ } ++ } + if (columnWrapperStyle) { + const { columnGap, rowGap, gap } = columnWrapperStyle; + const gapX = columnGap || gap || 0; +@@ -5745,7 +5924,8 @@ var ContainersLayer = typedMemo(function ContainersLayer2({ + } + } + } +- return /* @__PURE__ */ React2__namespace.createElement(ReactNative.Animated.View, { pointerEvents: isVisible ? void 0 : "none", style }, /* @__PURE__ */ React2__namespace.createElement(ContainerLayoutCoordinator, null, children)); ++ const content = /* @__PURE__ */ React2__namespace.createElement(ContainerLayoutCoordinator, null, children); ++ return SizeComponent ? /* @__PURE__ */ React2__namespace.createElement(SizeComponent, { horizontal, pointerEvents: isVisible ? void 0 : "none", signalName: "totalSize", style }, content) : /* @__PURE__ */ React2__namespace.createElement(ReactNative.Animated.View, { pointerEvents: isVisible ? void 0 : "none", style }, content); + }); + var Containers = typedMemo(function Containers2({ + freshDataTransitionEpoch, +@@ -5896,7 +6076,12 @@ var StyleSheet = ReactNative.StyleSheet; + + // src/components/ListComponent.tsx + var AlignItemsAtEndSpacer = typedMemo(function AlignItemsAtEndSpacer2({ horizontal }) { ++ const ctx = useStateContext(); + const [alignItemsAtEndPadding = 0] = useArr$(["alignItemsAtEndPadding"]); ++ const SizeComponent = ctx.state.props.sizeComponentInternal; ++ if (SizeComponent) { ++ return /* @__PURE__ */ React2__namespace.createElement(SizeComponent, { horizontal, signalName: "alignItemsAtEndPadding", style: { flexShrink: 0 } }); ++ } + if (alignItemsAtEndPadding <= 0) { + return null; + } +@@ -5929,8 +6114,12 @@ var ListComponent = typedMemo(function ListComponent2({ + refScrollView, + renderScrollComponent, + onLayoutFooter, ++ onInternalMomentumScrollBegin, + onInternalScrollBeginDrag, ++ onInternalScrollEndDrag, + onInternalScrollEnd, ++ onMomentumScrollBegin, ++ onScrollEndDrag, + scrollAdjustHandler, + snapToIndices, + stickyHeaderConfig, +@@ -6001,7 +6190,17 @@ var ListComponent = typedMemo(function ListComponent2({ + SnapOrScroll, + { + ...rest, +- ...Platform.OS === "web" ? ScrollComponent === ListComponentScrollView ? { onInternalScrollEnd, useWindowScroll } : {} : { onScrollBeginDrag: onInternalScrollBeginDrag }, ++ ...Platform.OS === "web" ? ScrollComponent === ListComponentScrollView ? { onInternalScrollEnd, useWindowScroll } : {} : { ++ onMomentumScrollBegin: (event) => { ++ onInternalMomentumScrollBegin == null ? void 0 : onInternalMomentumScrollBegin(event); ++ onMomentumScrollBegin == null ? void 0 : onMomentumScrollBegin(event); ++ }, ++ onScrollBeginDrag: onInternalScrollBeginDrag, ++ onScrollEndDrag: (event) => { ++ onInternalScrollEndDrag == null ? void 0 : onInternalScrollEndDrag(event); ++ onScrollEndDrag == null ? void 0 : onScrollEndDrag(event); ++ } ++ }, + contentContainerStyle: [ + horizontal ? { height: "100%" } : {}, + contentContainerStyle, +@@ -6751,7 +6950,7 @@ function createImperativeHandle(ctx, scheduleImperativeScrollCommit) { + endBuffered: state.endBuffered, + getAverageItemSizes: () => getAverageItemSizes(state), + indexByKey: (key) => state.indexByKey.get(key), +- isAtEnd: peek$(ctx, "isAtEnd"), ++ isAtEnd: getIsAtEnd(ctx), + isAtStart: peek$(ctx, "isAtStart"), + isEndReached: state.isEndReached, + isNearEnd: peek$(ctx, "isNearEnd"), +@@ -7075,6 +7274,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded dataVersion, drawDistance = 250, contentInsetEndAdjustment, @@ -513,7 +691,20 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..4227ed22da8865b7493f47f199f4d8f4 estimatedItemSize = 100, estimatedListSize, extraData, -@@ -7200,7 +7356,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7132,10 +7332,12 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded + const animatedPropsInternal = props.animatedPropsInternal; + const anchoredEndSpaceOwner = (_a3 = props.anchoredEndSpaceOwnerInternal) != null ? _a3 : "list"; + const positionComponentInternal = props.positionComponentInternal; ++ const sizeComponentInternal = props.sizeComponentInternal; + const stickyPositionComponentInternal = props.stickyPositionComponentInternal; + const { + anchoredEndSpaceOwnerInternal: _anchoredEndSpaceOwnerInternal, + positionComponentInternal: _positionComponentInternal, ++ sizeComponentInternal: _sizeComponentInternal, + stickyPositionComponentInternal: _stickyPositionComponentInternal, + ...restProps + } = rest; +@@ -7200,7 +7402,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded const combinedRef = useCombinedRef(refScroller, refScrollView); const keyExtractor = keyExtractorProp != null ? keyExtractorProp : ((_item, index) => index.toString()); const stickyHeaderIndices = stickyHeaderIndicesProp; @@ -522,7 +713,7 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..4227ed22da8865b7493f47f199f4d8f4 const previousContentInsetEndAdjustmentRef = React2.useRef(contentInsetEndAdjustmentResolved); const alwaysRenderIndices = React2.useMemo(() => { const indices = getAlwaysRenderIndices(alwaysRender, dataProp, keyExtractor, anchoredEndSpace == null ? void 0 : anchoredEndSpace.anchorIndex); -@@ -7341,6 +7497,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7341,6 +7543,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded contentContainerAlignItems: contentContainerStyle.alignItems, contentInset, contentInsetEndAdjustment: contentInsetEndAdjustmentResolved, @@ -530,7 +721,15 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..4227ed22da8865b7493f47f199f4d8f4 data: dataProp, dataKey, dataVersion, -@@ -7423,6 +7580,13 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7372,6 +7575,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded + renderItem, + rtl, + snapToIndices, ++ sizeComponentInternal, + stickyHeaderIndicesArr: stickyHeaderIndices != null ? stickyHeaderIndices : [], + stickyHeaderIndicesSet: React2.useMemo(() => new Set(stickyHeaderIndices != null ? stickyHeaderIndices : []), [stickyHeaderIndices == null ? void 0 : stickyHeaderIndices.join(",")]), + stickyPositionComponentInternal, +@@ -7423,6 +7627,13 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded return void 0; } const resolvedOffset = (_a4 = initialScroll.contentOffset) != null ? _a4 : resolveInitialScrollOffset(ctx, initialScroll); @@ -544,15 +743,43 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..4227ed22da8865b7493f47f199f4d8f4 return usesBootstrapInitialScroll && ((_b2 = state.initialScrollSession) == null ? void 0 : _b2.kind) === "bootstrap" && Platform.OS === "web" ? void 0 : resolvedOffset; }, [usesBootstrapInitialScroll]); React2.useLayoutEffect(() => { -@@ -7651,6 +7815,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7547,6 +7758,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded + [ + dataKey, + dataVersion, ++ contentInsetStartAdjustment, + memoizedLastItemKeys.join(","), + numColumnsProp, + nextScrollAxisGap, +@@ -7643,6 +7855,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded + () => ({ + getRenderedItem: (key) => getRenderedItem(ctx, key), + onMomentumScrollEnd: (event) => { ++ state.isMomentumScrolling = false; + checkFinishedScrollFallback(ctx); + if (state.props.onMomentumScrollEnd) { + state.props.onMomentumScrollEnd(event); +@@ -7651,6 +7864,8 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded onScroll: (event) => onScroll(ctx, event), onScrollBeginDrag: (event) => { var _a4, _b2; + ctx.state.didUserDrag = true; ++ ctx.state.isUserDragging = true; prepareReachedEdgeForNextUserScroll(ctx); (_b2 = (_a4 = state.props).onScrollBeginDrag) == null ? void 0 : _b2.call(_a4, event); }, -@@ -7681,6 +7846,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7676,11 +7891,18 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded + ListFooterComponent, + ListFooterComponentStyle, + ListHeaderComponent, ++ onInternalMomentumScrollBegin: () => { ++ ctx.state.isMomentumScrolling = true; ++ }, + onInternalScrollBeginDrag: fns.onScrollBeginDrag, ++ onInternalScrollEndDrag: () => { ++ ctx.state.isUserDragging = false; ++ }, + onInternalScrollEnd: fns.onScrollEnd, onLayout, onLayoutFooter, onMomentumScrollEnd: fns.onMomentumScrollEnd, @@ -561,10 +788,35 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..4227ed22da8865b7493f47f199f4d8f4 recycleItems, refreshControl: refreshControlElement ? stylePaddingTopState > 0 ? React2__namespace.cloneElement(refreshControlElement, { diff --git a/react-native.mjs b/react-native.mjs -index 40e87cda8c9bc79a889e5542f29af429a24b24d4..2d556eecb0bafe2f54084c809f82144c7a5dce7f 100644 +index 40e87cda8c9bc79a889e5542f29af429a24b24d4..90e0d1a9dfd07d0212aae308f547b9ff7e3ad022 100644 --- a/react-native.mjs +++ b/react-native.mjs -@@ -933,7 +933,7 @@ function setInitialRenderState(ctx, { +@@ -696,6 +696,15 @@ function hasActiveInitialScroll(state) { + return !!(state == null ? void 0 : state.initialScroll) && !state.didFinishInitialScroll; + } + ++// Size-only changes may not emit a scroll event to refresh the edge signal. ++function getIsAtEnd(ctx, contentSize = getContentSize(ctx)) { ++ const { queuedInitialLayout, scroll, scrollLength } = ctx.state; ++ if (!(contentSize > 0 && queuedInitialLayout)) { ++ return peek$(ctx, "isAtEnd"); ++ } ++ return contentSize < scrollLength || contentSize - scroll - scrollLength - getContentInsetEnd(ctx) <= EDGE_POSITION_EPSILON; ++} ++ + // src/utils/checkAtBottom.ts + function checkAtBottom(ctx, allowedEdge, allowGateCreatedInCurrentCheck) { + var _a3; +@@ -716,7 +725,7 @@ function checkAtBottom(ctx, allowedEdge, allowGateCreatedInCurrentCheck) { + const insetEnd = getContentInsetEnd(ctx); + const distanceFromEnd = contentSize - scroll - scrollLength - insetEnd; + const isContentLess = contentSize < scrollLength; +- set$(ctx, "isAtEnd", isContentLess || distanceFromEnd <= EDGE_POSITION_EPSILON); ++ set$(ctx, "isAtEnd", getIsAtEnd(ctx, contentSize)); + set$(ctx, "isNearEnd", isContentLess || distanceFromEnd <= onEndReachedThreshold * scrollLength); + set$( + ctx, +@@ -933,7 +942,7 @@ function setInitialRenderState(ctx, { if (didInitialScroll) { state.didFinishInitialScroll = true; } @@ -573,7 +825,27 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..2d556eecb0bafe2f54084c809f82144c if (isReadyToRender && !peek$(ctx, "readyToRender")) { set$(ctx, "readyToRender", true); setAdaptiveRender(ctx, "normal", "ready"); -@@ -1283,18 +1283,23 @@ function calculateOffsetWithOffsetPosition(ctx, offsetParam, params) { +@@ -1069,7 +1078,7 @@ function getRawContentLength(ctx) { + function getAlignItemsAtEndPadding(ctx) { + const { state } = ctx; + const shouldPad = !!state.props.alignItemsAtEndPaddingEnabled && !state.props.horizontal && state.props.data.length > 0 && state.scrollLength > 0; +- return shouldPad ? Math.max(0, state.scrollLength - getRawContentLength(ctx) - getContentInsetEnd(ctx)) : 0; ++ return shouldPad ? Math.max(0, state.scrollLength - getContentInsetStartAdjustment(ctx) - getRawContentLength(ctx) - getContentInsetEnd(ctx)) : 0; + } + function updateContentMetricsState(ctx) { + const previousPadding = peek$(ctx, "alignItemsAtEndPadding") || 0; +@@ -1094,6 +1103,10 @@ function addTotalSize(ctx, key, add, notifyTotalSize = true) { + totalSize += add; + } + if (prevTotalSize !== totalSize) { ++ const now = Date.now(); ++ const isContinuingContentSizeAnimation = state.contentSizeAnimationActiveEpoch !== void 0; ++ state.contentSizeAnimationEpoch = (state.contentSizeAnimationEpoch || 0) + 1; ++ state.contentSizeAnimationEligible = !!state.props.sizeComponentInternal && state.didContainersLayout && (isContinuingContentSizeAnimation || !state.props.maintainScrollAtEnd && !state.isUserDragging && !state.isMomentumScrolling && now - (state.lastMVCPAdjustTime || 0) >= 300); + if (!IsNewArchitecture && state.initialScroll && totalSize < prevTotalSize) { + state.pendingTotalSize = totalSize; + } else { +@@ -1283,18 +1296,23 @@ function calculateOffsetWithOffsetPosition(ctx, offsetParam, params) { } // src/core/clampScrollOffset.ts @@ -599,7 +871,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..2d556eecb0bafe2f54084c809f82144c return clampedOffset; } -@@ -1430,10 +1435,10 @@ function checkFinishedScrollFrame(ctx) { +@@ -1430,10 +1448,10 @@ function checkFinishedScrollFrame(ctx) { finishScrollTo(ctx); } } @@ -612,7 +884,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..2d556eecb0bafe2f54084c809f82144c x: ctx.state.props.horizontal ? offset : 0, y: ctx.state.props.horizontal ? 0 : offset }); -@@ -1482,7 +1487,10 @@ function checkFinishedScrollFallback(ctx) { +@@ -1482,7 +1500,10 @@ function checkFinishedScrollFallback(ctx) { ); scheduleFallbackCheck(SILENT_INITIAL_SCROLL_RETRY_DELAY_MS); } else if (shouldRetryUnalignedEndScroll) { @@ -624,7 +896,17 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..2d556eecb0bafe2f54084c809f82144c scheduleFallbackCheck(100); } else if (shouldFinishZeroTarget || shouldFinishAfterObservedScroll || canFinishInitialScrollWithoutNativeProgress || canFinishAfterSilentNativeDispatch || numChecks > maxChecks) { finishScrollTo(ctx); -@@ -1545,9 +1553,18 @@ function doMaintainScrollAtEnd(ctx) { +@@ -1539,15 +1560,28 @@ function doMaintainScrollAtEnd(ctx) { + } = state; + const isWithinMaintainScrollAtEndThreshold = peek$(ctx, "isWithinMaintainScrollAtEndThreshold"); + const shouldMaintainScrollAtEnd = !!(isWithinMaintainScrollAtEndThreshold && maintainScrollAtEnd && didContainersLayout); ++ if (state.contentSizeAnimationActiveEpoch !== void 0) { ++ state.pendingMaintainScrollAtEnd = false; ++ return false; ++ } + if (pendingNativeMVCPAdjust) { + state.pendingMaintainScrollAtEnd = shouldMaintainScrollAtEnd; + return false; } if (shouldMaintainScrollAtEnd) { state.pendingMaintainScrollAtEnd = false; @@ -644,7 +926,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..2d556eecb0bafe2f54084c809f82144c } if (!state.maintainingScrollAtEnd) { const pendingState = maintainScrollAtEnd.animated ? "pending-animated" : "pending-instant"; -@@ -1570,9 +1587,18 @@ function doMaintainScrollAtEnd(ctx) { +@@ -1570,9 +1604,18 @@ function doMaintainScrollAtEnd(ctx) { y: 0 }); } else { @@ -666,7 +948,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..2d556eecb0bafe2f54084c809f82144c } setTimeout( () => { -@@ -1603,6 +1629,10 @@ function doMaintainScrollAtEnd(ctx) { +@@ -1603,6 +1646,10 @@ function doMaintainScrollAtEnd(ctx) { function requestAdjust(ctx, positionDiff, dataChanged) { const state = ctx.state; if (Math.abs(positionDiff) > 0.1) { @@ -677,7 +959,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..2d556eecb0bafe2f54084c809f82144c const needsScrollWorkaround = Platform.OS === "android" && !IsNewArchitecture && dataChanged && state.scroll <= positionDiff; const doit = () => { if (needsScrollWorkaround) { -@@ -1707,7 +1737,9 @@ function getPredictedNativeClamp(state, unresolvedAmount, totalSize) { +@@ -1707,7 +1754,9 @@ function getPredictedNativeClamp(state, unresolvedAmount, totalSize) { if (Math.abs(unresolvedAmount) <= MVCP_POSITION_EPSILON) { return 0; } @@ -688,7 +970,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..2d556eecb0bafe2f54084c809f82144c const clampDelta = maxScroll - state.scroll; if (unresolvedAmount < 0) { return Math.max(unresolvedAmount, Math.min(0, clampDelta)); -@@ -1769,7 +1801,7 @@ function resolvePendingNativeMVCPAdjust(ctx, newScroll) { +@@ -1769,7 +1818,7 @@ function resolvePendingNativeMVCPAdjust(ctx, newScroll) { settlePendingNativeMVCPAdjust(ctx, remainingAfterManual, nativeDelta); return true; } @@ -697,7 +979,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..2d556eecb0bafe2f54084c809f82144c const distanceToClamp = Math.abs(newScroll - expectedNativeClampScroll); const isAtExpectedNativeClamp = distanceToClamp <= NATIVE_END_CLAMP_EPSILON; if (isAtExpectedNativeClamp) { -@@ -1902,7 +1934,7 @@ function prepareMVCP(ctx, dataChanged) { +@@ -1902,7 +1951,7 @@ function prepareMVCP(ctx, dataChanged) { if (diff > 0) { diff = Math.max(0, totalSize - state.scroll - state.scrollLength); } else { @@ -706,7 +988,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..2d556eecb0bafe2f54084c809f82144c state.scroll = maxScroll; state.scrollPending = maxScroll; diff = 0; -@@ -2299,8 +2331,121 @@ function scrollToIndex(ctx, { +@@ -2299,8 +2348,121 @@ function scrollToIndex(ctx, { } // src/core/initialScroll.ts @@ -828,7 +1110,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..2d556eecb0bafe2f54084c809f82144c const requestedIndex = target.index; const index = requestedIndex !== void 0 ? clampScrollIndex(requestedIndex, ctx.state.props.data.length) : void 0; const itemSize = getItemSizeAtIndex(ctx, index); -@@ -2726,7 +2871,9 @@ function clearFinishedBootstrapInitialScrollTargetIfMovedAway(ctx) { +@@ -2726,7 +2888,9 @@ function clearFinishedBootstrapInitialScrollTargetIfMovedAway(ctx) { return; } if (didFinishedInitialScrollMoveAwayFromTarget(ctx, initialScroll)) { @@ -839,7 +1121,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..2d556eecb0bafe2f54084c809f82144c if (!shouldKeepEndTargetAlive) { if (shouldPreserveInitialScrollForFooterLayout(initialScroll)) { clearPendingInitialScrollFooterLayout(ctx, { -@@ -4651,7 +4798,8 @@ function maybeUpdateAnchoredEndSpace(ctx) { +@@ -4651,7 +4815,8 @@ function maybeUpdateAnchoredEndSpace(ctx) { contentBelowAnchor = Math.max(0, contentBelowAnchor - ctx.scrollAxisGap); contentBelowAnchor += (ctx.values.get("footerSize") || 0) + getStylePaddingEnd(state.props); isReady = !hasUnknownTailSize; @@ -849,7 +1131,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..2d556eecb0bafe2f54084c809f82144c } else if (anchorIndex >= 0) { isReady = false; } -@@ -4671,6 +4819,12 @@ function maybeUpdateAnchoredEndSpace(ctx) { +@@ -4671,6 +4836,12 @@ function maybeUpdateAnchoredEndSpace(ctx) { updateScroll(ctx, state.scroll, true, { markHasScrolled: false }); } (_b = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onReady) == null ? void 0 : _b.call(anchoredEndSpace, { anchorIndex: nextAnchorIndex, anchorKey: nextAnchorKey, size: nextSize }); @@ -862,7 +1144,93 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..2d556eecb0bafe2f54084c809f82144c } return nextSize; } -@@ -7054,6 +7208,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -5694,6 +5865,7 @@ var ContainersLayer = typedMemo(function ContainersLayer2({ + horizontal + }) { + const ctx = useStateContext(); ++ const SizeComponent = ctx.state.props.sizeComponentInternal; + const columnWrapperStyle = ctx.columnWrapperStyle; + const animSize = useValue$("totalSize"); + const [readyToRender, numColumns, otherAxisSize = 0] = useArr$(["readyToRender", "numColumns", "otherAxisSize"]); +@@ -5704,6 +5876,13 @@ var ContainersLayer = typedMemo(function ContainersLayer2({ + opacity: isVisible ? 1 : 0, + width: animSize + } : { height: animSize, minWidth: otherAxisSize, opacity: isVisible ? 1 : 0 }; ++ if (SizeComponent) { ++ if (horizontal) { ++ delete style.width; ++ } else { ++ delete style.height; ++ } ++ } + if (columnWrapperStyle) { + const { columnGap, rowGap, gap } = columnWrapperStyle; + const gapX = columnGap || gap || 0; +@@ -5724,7 +5903,8 @@ var ContainersLayer = typedMemo(function ContainersLayer2({ + } + } + } +- return /* @__PURE__ */ React2.createElement(Animated.View, { pointerEvents: isVisible ? void 0 : "none", style }, /* @__PURE__ */ React2.createElement(ContainerLayoutCoordinator, null, children)); ++ const content = /* @__PURE__ */ React2.createElement(ContainerLayoutCoordinator, null, children); ++ return SizeComponent ? /* @__PURE__ */ React2.createElement(SizeComponent, { horizontal, pointerEvents: isVisible ? void 0 : "none", signalName: "totalSize", style }, content) : /* @__PURE__ */ React2.createElement(Animated.View, { pointerEvents: isVisible ? void 0 : "none", style }, content); + }); + var Containers = typedMemo(function Containers2({ + freshDataTransitionEpoch, +@@ -5875,7 +6055,12 @@ var StyleSheet = StyleSheet$1; + + // src/components/ListComponent.tsx + var AlignItemsAtEndSpacer = typedMemo(function AlignItemsAtEndSpacer2({ horizontal }) { ++ const ctx = useStateContext(); + const [alignItemsAtEndPadding = 0] = useArr$(["alignItemsAtEndPadding"]); ++ const SizeComponent = ctx.state.props.sizeComponentInternal; ++ if (SizeComponent) { ++ return /* @__PURE__ */ React2.createElement(SizeComponent, { horizontal, signalName: "alignItemsAtEndPadding", style: { flexShrink: 0 } }); ++ } + if (alignItemsAtEndPadding <= 0) { + return null; + } +@@ -5908,8 +6093,12 @@ var ListComponent = typedMemo(function ListComponent2({ + refScrollView, + renderScrollComponent, + onLayoutFooter, ++ onInternalMomentumScrollBegin, + onInternalScrollBeginDrag, ++ onInternalScrollEndDrag, + onInternalScrollEnd, ++ onMomentumScrollBegin, ++ onScrollEndDrag, + scrollAdjustHandler, + snapToIndices, + stickyHeaderConfig, +@@ -5980,7 +6169,17 @@ var ListComponent = typedMemo(function ListComponent2({ + SnapOrScroll, + { + ...rest, +- ...Platform.OS === "web" ? ScrollComponent === ListComponentScrollView ? { onInternalScrollEnd, useWindowScroll } : {} : { onScrollBeginDrag: onInternalScrollBeginDrag }, ++ ...Platform.OS === "web" ? ScrollComponent === ListComponentScrollView ? { onInternalScrollEnd, useWindowScroll } : {} : { ++ onMomentumScrollBegin: (event) => { ++ onInternalMomentumScrollBegin == null ? void 0 : onInternalMomentumScrollBegin(event); ++ onMomentumScrollBegin == null ? void 0 : onMomentumScrollBegin(event); ++ }, ++ onScrollBeginDrag: onInternalScrollBeginDrag, ++ onScrollEndDrag: (event) => { ++ onInternalScrollEndDrag == null ? void 0 : onInternalScrollEndDrag(event); ++ onScrollEndDrag == null ? void 0 : onScrollEndDrag(event); ++ } ++ }, + contentContainerStyle: [ + horizontal ? { height: "100%" } : {}, + contentContainerStyle, +@@ -6730,7 +6929,7 @@ function createImperativeHandle(ctx, scheduleImperativeScrollCommit) { + endBuffered: state.endBuffered, + getAverageItemSizes: () => getAverageItemSizes(state), + indexByKey: (key) => state.indexByKey.get(key), +- isAtEnd: peek$(ctx, "isAtEnd"), ++ isAtEnd: getIsAtEnd(ctx), + isAtStart: peek$(ctx, "isAtStart"), + isEndReached: state.isEndReached, + isNearEnd: peek$(ctx, "isNearEnd"), +@@ -7054,6 +7253,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded dataVersion, drawDistance = 250, contentInsetEndAdjustment, @@ -870,7 +1238,20 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..2d556eecb0bafe2f54084c809f82144c estimatedItemSize = 100, estimatedListSize, extraData, -@@ -7179,7 +7334,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7111,10 +7311,12 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded + const animatedPropsInternal = props.animatedPropsInternal; + const anchoredEndSpaceOwner = (_a3 = props.anchoredEndSpaceOwnerInternal) != null ? _a3 : "list"; + const positionComponentInternal = props.positionComponentInternal; ++ const sizeComponentInternal = props.sizeComponentInternal; + const stickyPositionComponentInternal = props.stickyPositionComponentInternal; + const { + anchoredEndSpaceOwnerInternal: _anchoredEndSpaceOwnerInternal, + positionComponentInternal: _positionComponentInternal, ++ sizeComponentInternal: _sizeComponentInternal, + stickyPositionComponentInternal: _stickyPositionComponentInternal, + ...restProps + } = rest; +@@ -7179,7 +7381,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded const combinedRef = useCombinedRef(refScroller, refScrollView); const keyExtractor = keyExtractorProp != null ? keyExtractorProp : ((_item, index) => index.toString()); const stickyHeaderIndices = stickyHeaderIndicesProp; @@ -879,7 +1260,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..2d556eecb0bafe2f54084c809f82144c const previousContentInsetEndAdjustmentRef = useRef(contentInsetEndAdjustmentResolved); const alwaysRenderIndices = useMemo(() => { const indices = getAlwaysRenderIndices(alwaysRender, dataProp, keyExtractor, anchoredEndSpace == null ? void 0 : anchoredEndSpace.anchorIndex); -@@ -7320,6 +7475,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7320,6 +7522,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded contentContainerAlignItems: contentContainerStyle.alignItems, contentInset, contentInsetEndAdjustment: contentInsetEndAdjustmentResolved, @@ -887,7 +1268,15 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..2d556eecb0bafe2f54084c809f82144c data: dataProp, dataKey, dataVersion, -@@ -7402,6 +7558,13 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7351,6 +7554,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded + renderItem, + rtl, + snapToIndices, ++ sizeComponentInternal, + stickyHeaderIndicesArr: stickyHeaderIndices != null ? stickyHeaderIndices : [], + stickyHeaderIndicesSet: useMemo(() => new Set(stickyHeaderIndices != null ? stickyHeaderIndices : []), [stickyHeaderIndices == null ? void 0 : stickyHeaderIndices.join(",")]), + stickyPositionComponentInternal, +@@ -7402,6 +7606,13 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded return void 0; } const resolvedOffset = (_a4 = initialScroll.contentOffset) != null ? _a4 : resolveInitialScrollOffset(ctx, initialScroll); @@ -901,15 +1290,43 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..2d556eecb0bafe2f54084c809f82144c return usesBootstrapInitialScroll && ((_b2 = state.initialScrollSession) == null ? void 0 : _b2.kind) === "bootstrap" && Platform.OS === "web" ? void 0 : resolvedOffset; }, [usesBootstrapInitialScroll]); useLayoutEffect(() => { -@@ -7630,6 +7793,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7526,6 +7737,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded + [ + dataKey, + dataVersion, ++ contentInsetStartAdjustment, + memoizedLastItemKeys.join(","), + numColumnsProp, + nextScrollAxisGap, +@@ -7622,6 +7834,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded + () => ({ + getRenderedItem: (key) => getRenderedItem(ctx, key), + onMomentumScrollEnd: (event) => { ++ state.isMomentumScrolling = false; + checkFinishedScrollFallback(ctx); + if (state.props.onMomentumScrollEnd) { + state.props.onMomentumScrollEnd(event); +@@ -7630,6 +7843,8 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded onScroll: (event) => onScroll(ctx, event), onScrollBeginDrag: (event) => { var _a4, _b2; + ctx.state.didUserDrag = true; ++ ctx.state.isUserDragging = true; prepareReachedEdgeForNextUserScroll(ctx); (_b2 = (_a4 = state.props).onScrollBeginDrag) == null ? void 0 : _b2.call(_a4, event); }, -@@ -7660,6 +7824,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7655,11 +7870,18 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded + ListFooterComponent, + ListFooterComponentStyle, + ListHeaderComponent, ++ onInternalMomentumScrollBegin: () => { ++ ctx.state.isMomentumScrolling = true; ++ }, + onInternalScrollBeginDrag: fns.onScrollBeginDrag, ++ onInternalScrollEndDrag: () => { ++ ctx.state.isUserDragging = false; ++ }, + onInternalScrollEnd: fns.onScrollEnd, onLayout, onLayoutFooter, onMomentumScrollEnd: fns.onMomentumScrollEnd, @@ -918,7 +1335,7 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..2d556eecb0bafe2f54084c809f82144c recycleItems, refreshControl: refreshControlElement ? stylePaddingTopState > 0 ? React2.cloneElement(refreshControlElement, { diff --git a/react.js b/react.js -index 914d2dafaafa001c9ab6791a0a0581659295e333..06466096ceb96ef8773f7f7e202a19c9b774d49b 100644 +index 914d2dafaafa001c9ab6791a0a0581659295e333..a2df18d450eebe451fda42bab47d1658458edbb1 100644 --- a/react.js +++ b/react.js @@ -4702,7 +4702,8 @@ function maybeUpdateAnchoredEndSpace(ctx) { @@ -931,8 +1348,7 @@ index 914d2dafaafa001c9ab6791a0a0581659295e333..06466096ceb96ef8773f7f7e202a19c9 } else if (anchorIndex >= 0) { isReady = false; } -@@ -4721,7 +4722,13 @@ function maybeUpdateAnchoredEndSpace(ctx) { - updateContentMetricsState(ctx); +@@ -4722,6 +4723,12 @@ function maybeUpdateAnchoredEndSpace(ctx) { updateScroll(ctx, state.scroll, true, { markHasScrolled: false }); } (_b = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onReady) == null ? void 0 : _b.call(anchoredEndSpace, { anchorIndex: nextAnchorIndex, anchorKey: nextAnchorKey, size: nextSize }); @@ -945,7 +1361,7 @@ index 914d2dafaafa001c9ab6791a0a0581659295e333..06466096ceb96ef8773f7f7e202a19c9 } return nextSize; } -@@ -6448,8 +6455,8 @@ function ScrollAdjust() { +@@ -6446,8 +6453,8 @@ function ScrollAdjust() { window.getComputedStyle(contentNode)[axis.paddingEndProp] ); const temporaryPaddingEnd = `${(currentPaddingEnd || 0) + pad}px`; @@ -956,7 +1372,7 @@ index 914d2dafaafa001c9ab6791a0a0581659295e333..06466096ceb96ef8773f7f7e202a19c9 scrollBy(); if (resetPaddingRafRef.current !== void 0) { diff --git a/react.mjs b/react.mjs -index 95465f2ab89ce41a10553f58af83618f7310e83c..4b5f06c60f1e8bbd9035a49980d9e86820262da4 100644 +index 95465f2ab89ce41a10553f58af83618f7310e83c..25cf046f2c3141ddce5a0b6e28c354b865331b4f 100644 --- a/react.mjs +++ b/react.mjs @@ -4681,7 +4681,8 @@ function maybeUpdateAnchoredEndSpace(ctx) { @@ -969,8 +1385,7 @@ index 95465f2ab89ce41a10553f58af83618f7310e83c..4b5f06c60f1e8bbd9035a49980d9e868 } else if (anchorIndex >= 0) { isReady = false; } -@@ -4700,7 +4701,13 @@ function maybeUpdateAnchoredEndSpace(ctx) { - updateContentMetricsState(ctx); +@@ -4701,6 +4702,12 @@ function maybeUpdateAnchoredEndSpace(ctx) { updateScroll(ctx, state.scroll, true, { markHasScrolled: false }); } (_b = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onReady) == null ? void 0 : _b.call(anchoredEndSpace, { anchorIndex: nextAnchorIndex, anchorKey: nextAnchorKey, size: nextSize }); @@ -983,7 +1398,7 @@ index 95465f2ab89ce41a10553f58af83618f7310e83c..4b5f06c60f1e8bbd9035a49980d9e868 } return nextSize; } -@@ -6427,8 +6434,8 @@ function ScrollAdjust() { +@@ -6425,8 +6432,8 @@ function ScrollAdjust() { window.getComputedStyle(contentNode)[axis.paddingEndProp] ); const temporaryPaddingEnd = `${(currentPaddingEnd || 0) + pad}px`; @@ -1011,19 +1426,22 @@ index e5043320700b12f34f4c0babbc341f85ca8135c1..2ce63830a28636b21937a0741fd0e613 * Number of columns to render items in. * @default 1 diff --git a/reanimated.js b/reanimated.js -index f1265fad74189591b5aae86cf2e3a31f9c0fdb02..16dcef04a6591d500c724635df272274123bad2d 100644 +index f1265fad74189591b5aae86cf2e3a31f9c0fdb02..fc03be2190c046f743ffde45ed189ee7868d0cec 100644 --- a/reanimated.js +++ b/reanimated.js -@@ -116,7 +116,7 @@ var ReanimatedPositionView = typedMemo(function ReanimatedPositionViewComponent( +@@ -115,8 +115,10 @@ var ReanimatedPositionView = typedMemo(function ReanimatedPositionViewComponent( + const { id, horizontal, style, refView, children, recycleItems, layoutTransition, ...rest } = props; const [positionValue = POSITION_OUT_OF_VIEW] = useArr$([`containerPosition${id}`]); const prevItemKeyRef = React__namespace.useRef(void 0); ++ const previousPositionRef = React__namespace.useRef(positionValue); ++ const lastContentSizeEpochRef = React__namespace.useRef(ctx.state.contentSizeAnimationEpoch || 0); let shouldSkipTransitionForRecycleReuse = false; - if (recycleItems && layoutTransition) { + if (layoutTransition) { const itemKeySignal = `containerItemKey${id}`; const itemKey = peek$(ctx, itemKeySignal); shouldSkipTransitionForRecycleReuse = itemKey !== void 0 && prevItemKeyRef.current !== void 0 && prevItemKeyRef.current !== itemKey; -@@ -130,10 +130,20 @@ var ReanimatedPositionView = typedMemo(function ReanimatedPositionViewComponent( +@@ -130,10 +132,29 @@ var ReanimatedPositionView = typedMemo(function ReanimatedPositionViewComponent( () => [style, horizontal ? { left: positionValue } : { top: positionValue }], [horizontal, positionValue, style] ); @@ -1035,30 +1453,167 @@ index f1265fad74189591b5aae86cf2e3a31f9c0fdb02..16dcef04a6591d500c724635df272274 + // transition exists to smooth at-rest shifts (streaming text, work-log + // folds), so gate it to at-rest moments. + const now = Date.now(); -+ const isMVCPReposition = now - (ctx.state.lastMVCPAdjustTime || 0) < 300 || -+ now - (ctx.state.lastNativeScrollTime || 0) < 300; ++ const contentSizeEpoch = ctx.state.contentSizeAnimationEpoch || 0; ++ const didPositionChange = positionValue !== previousPositionRef.current; ++ const isRawSizeReposition = didPositionChange && contentSizeEpoch !== lastContentSizeEpochRef.current && ctx.state.contentSizeAnimationEligible && !!layoutTransition && typeof layoutTransition.getAnimationAndConfig === "function"; ++ React__namespace.useLayoutEffect(() => { ++ if (didPositionChange) { ++ previousPositionRef.current = positionValue; ++ lastContentSizeEpochRef.current = contentSizeEpoch; ++ } ++ }, [contentSizeEpoch, didPositionChange, positionValue]); ++ const isRecentNativeReposition = now - (ctx.state.lastNativeScrollTime || 0) < 300; ++ const shouldSkipLayoutTransition = now - (ctx.state.lastMVCPAdjustTime || 0) < 300 || ctx.state.isUserDragging || ctx.state.isMomentumScrolling || isRecentNativeReposition && !isRawSizeReposition; return /* @__PURE__ */ React__namespace.createElement( Reanimated__default.default.View, { - layout: shouldSkipTransitionForRecycleReuse ? void 0 : layoutTransition, -+ layout: shouldSkipTransitionForRecycleReuse || isMVCPReposition ? void 0 : layoutTransition, ++ layout: shouldSkipTransitionForRecycleReuse || shouldSkipLayoutTransition ? void 0 : layoutTransition, ref: refView, style: viewStyle, ...rest +@@ -141,6 +162,95 @@ var ReanimatedPositionView = typedMemo(function ReanimatedPositionViewComponent( + children + ); + }); ++var ReanimatedSizeView = typedMemo(function ReanimatedSizeViewComponent(props) { ++ const ctx = useStateContext(); ++ const { horizontal, signalName, style, children, ...rest } = props; ++ const [logicalSize = 0] = useArr$(signalName === "totalSize" ? [signalName] : [signalName, "totalSize"]); ++ const baseSize = Reanimated.useSharedValue(logicalSize); ++ const animatedDelta = Reanimated.useSharedValue(0); ++ const previousLogicalSizeRef = React__namespace.useRef(logicalSize); ++ const previousEpochRef = React__namespace.useRef(ctx.state.contentSizeAnimationEpoch || 0); ++ const animationRunRef = React__namespace.useRef(0); ++ const isAnimatingRef = React__namespace.useRef(false); ++ const transition = props.layoutTransition; ++ const canAnimate = !!transition && typeof transition.getAnimationAndConfig === "function"; ++ const renderEpoch = ctx.state.contentSizeAnimationEpoch || 0; ++ // Keep the animated size attached after transitions finish. Reanimated ++ // retains removed animated props, so switching to a static size leaves ++ // native scroll bounds stuck while logical measurements keep changing. ++ const animatedStyle = Reanimated.useAnimatedStyle(() => { ++ const size = Math.max(0, baseSize.value + animatedDelta.value); ++ return horizontal ? { width: size } : { height: size }; ++ }, [horizontal]); ++ const completeAnimation = React__namespace.useCallback((run, epoch) => { ++ if (animationRunRef.current !== run) { ++ return; ++ } ++ isAnimatingRef.current = false; ++ const state = ctx.state; ++ if (state.contentSizeAnimationActiveEpoch !== epoch) { ++ return; ++ } ++ state.contentSizeAnimationActiveSignals.delete(signalName); ++ if (state.contentSizeAnimationActiveSignals.size === 0) { ++ state.contentSizeAnimationActiveEpoch = void 0; ++ state.contentSizeAnimationEligible = false; ++ } ++ }, [ctx.state, signalName]); ++ React__namespace.useLayoutEffect(() => { ++ const state = ctx.state; ++ const epoch = state.contentSizeAnimationEpoch || 0; ++ const previousLogicalSize = previousLogicalSizeRef.current; ++ const delta = logicalSize - previousLogicalSize; ++ const isNewEpoch = epoch !== previousEpochRef.current; ++ // Retarget measurements received mid-animation, even in the same epoch. ++ // Updating only the base keeps the old delta and can shrink scroll bounds. ++ const shouldAnimate = canAnimate && (isAnimatingRef.current || isNewEpoch && state.contentSizeAnimationEligible); ++ previousLogicalSizeRef.current = logicalSize; ++ previousEpochRef.current = epoch; ++ if (!delta) { ++ return; ++ } ++ if (!shouldAnimate) { ++ baseSize.value = logicalSize; ++ if (signalName === "totalSize" && isNewEpoch && state.contentSizeAnimationEligible && !canAnimate) { ++ state.contentSizeAnimationEligible = false; ++ } ++ return; ++ } ++ const [animation, config] = transition.getAnimationAndConfig(); ++ const delayFunction = typeof transition.getDelayFunction === "function" ? transition.getDelayFunction() : (_, value) => value; ++ const delay = typeof transition.getDelay === "function" ? transition.getDelay() : 0; ++ if (typeof animation !== "function" || typeof delayFunction !== "function") { ++ baseSize.value = logicalSize; ++ if (signalName === "totalSize") { ++ state.contentSizeAnimationEligible = false; ++ } ++ return; ++ } ++ if (state.contentSizeAnimationActiveEpoch !== epoch) { ++ state.contentSizeAnimationActiveEpoch = epoch; ++ state.contentSizeAnimationActiveSignals = /* @__PURE__ */ new Set(); ++ } ++ state.contentSizeAnimationActiveSignals.add(signalName); ++ const wasAnimating = isAnimatingRef.current; ++ isAnimatingRef.current = true; ++ const run = ++animationRunRef.current; ++ Reanimated.runOnUI((absoluteSize, previousSize, continuesAnimation, animationRun, animationEpoch) => { ++ "worklet"; ++ const currentVisualSize = continuesAnimation ? baseSize.value + animatedDelta.value : previousSize; ++ baseSize.value = absoluteSize; ++ animatedDelta.value = currentVisualSize - absoluteSize; ++ animatedDelta.value = delayFunction(delay, animation(0, config, (finished) => { ++ "worklet"; ++ if (finished) { ++ Reanimated.runOnJS(completeAnimation)(animationRun, animationEpoch); ++ } ++ })); ++ })(logicalSize, previousLogicalSize, wasAnimating, run, epoch); ++ }, [baseSize, animatedDelta, canAnimate, completeAnimation, ctx.state, logicalSize, renderEpoch, signalName, transition]); ++ return /* @__PURE__ */ React__namespace.createElement(Reanimated__default.default.View, { ...rest, style: [style, animatedStyle] }, children); ++}); + function setSharedValueValue(sharedValue, value) { + if (!sharedValue) { + return; +@@ -245,10 +355,19 @@ var LegendListForwardedRef = typedMemo( + ); + }; + }, [hasItemLayoutAnimation, recycleItems]); ++ const sizeComponentInternal = React__namespace.useMemo(() => { ++ if (!hasItemLayoutAnimation) { ++ return void 0; ++ } ++ return function SizeComponent(sizeProps) { ++ return /* @__PURE__ */ React__namespace.createElement(ReanimatedSizeView, { ...sizeProps, layoutTransition: itemLayoutAnimationRef.current }); ++ }; ++ }, [hasItemLayoutAnimation]); + const legendListProps = { + ...rest, + positionComponentInternal, + recycleItems, ++ sizeComponentInternal, + ...{ + renderScrollComponent: renderReanimatedScrollComponent, + ...IsNewArchitecture ? { stickyPositionComponentInternal } : {} diff --git a/reanimated.mjs b/reanimated.mjs -index 29a00d5dc084fd408a0e0a0a4d64e856d0ed1525..9c25ec5dd79fb137c073adc24643ad8a2996bf56 100644 +index 29a00d5dc084fd408a0e0a0a4d64e856d0ed1525..6ab929250c8924e2d9919ee09a4f774238fbb90a 100644 --- a/reanimated.mjs +++ b/reanimated.mjs -@@ -92,7 +92,7 @@ var ReanimatedPositionView = typedMemo(function ReanimatedPositionViewComponent( +@@ -1,7 +1,7 @@ + import * as React from 'react'; + import { useCallback } from 'react'; + import { View } from 'react-native'; +-import Reanimated, { useAnimatedRef, useAnimatedStyle, useSharedValue, useScrollViewOffset } from 'react-native-reanimated'; ++import Reanimated, { runOnJS, runOnUI, useAnimatedRef, useAnimatedStyle, useSharedValue, useScrollViewOffset } from 'react-native-reanimated'; + import { internal, LegendList } from '@legendapp/list/react-native'; + + // src/integrations/reanimated.tsx +@@ -91,8 +91,10 @@ var ReanimatedPositionView = typedMemo(function ReanimatedPositionViewComponent( + const { id, horizontal, style, refView, children, recycleItems, layoutTransition, ...rest } = props; const [positionValue = POSITION_OUT_OF_VIEW] = useArr$([`containerPosition${id}`]); const prevItemKeyRef = React.useRef(void 0); ++ const previousPositionRef = React.useRef(positionValue); ++ const lastContentSizeEpochRef = React.useRef(ctx.state.contentSizeAnimationEpoch || 0); let shouldSkipTransitionForRecycleReuse = false; - if (recycleItems && layoutTransition) { + if (layoutTransition) { const itemKeySignal = `containerItemKey${id}`; const itemKey = peek$(ctx, itemKeySignal); shouldSkipTransitionForRecycleReuse = itemKey !== void 0 && prevItemKeyRef.current !== void 0 && prevItemKeyRef.current !== itemKey; -@@ -106,10 +106,20 @@ var ReanimatedPositionView = typedMemo(function ReanimatedPositionViewComponent( +@@ -106,10 +108,29 @@ var ReanimatedPositionView = typedMemo(function ReanimatedPositionViewComponent( () => [style, horizontal ? { left: positionValue } : { top: positionValue }], [horizontal, positionValue, style] ); @@ -1070,13 +1625,138 @@ index 29a00d5dc084fd408a0e0a0a4d64e856d0ed1525..9c25ec5dd79fb137c073adc24643ad8a + // transition exists to smooth at-rest shifts (streaming text, work-log + // folds), so gate it to at-rest moments. + const now = Date.now(); -+ const isMVCPReposition = now - (ctx.state.lastMVCPAdjustTime || 0) < 300 || -+ now - (ctx.state.lastNativeScrollTime || 0) < 300; ++ const contentSizeEpoch = ctx.state.contentSizeAnimationEpoch || 0; ++ const didPositionChange = positionValue !== previousPositionRef.current; ++ const isRawSizeReposition = didPositionChange && contentSizeEpoch !== lastContentSizeEpochRef.current && ctx.state.contentSizeAnimationEligible && !!layoutTransition && typeof layoutTransition.getAnimationAndConfig === "function"; ++ React.useLayoutEffect(() => { ++ if (didPositionChange) { ++ previousPositionRef.current = positionValue; ++ lastContentSizeEpochRef.current = contentSizeEpoch; ++ } ++ }, [contentSizeEpoch, didPositionChange, positionValue]); ++ const isRecentNativeReposition = now - (ctx.state.lastNativeScrollTime || 0) < 300; ++ const shouldSkipLayoutTransition = now - (ctx.state.lastMVCPAdjustTime || 0) < 300 || ctx.state.isUserDragging || ctx.state.isMomentumScrolling || isRecentNativeReposition && !isRawSizeReposition; return /* @__PURE__ */ React.createElement( Reanimated.View, { - layout: shouldSkipTransitionForRecycleReuse ? void 0 : layoutTransition, -+ layout: shouldSkipTransitionForRecycleReuse || isMVCPReposition ? void 0 : layoutTransition, ++ layout: shouldSkipTransitionForRecycleReuse || shouldSkipLayoutTransition ? void 0 : layoutTransition, ref: refView, style: viewStyle, ...rest +@@ -117,6 +138,95 @@ var ReanimatedPositionView = typedMemo(function ReanimatedPositionViewComponent( + children + ); + }); ++var ReanimatedSizeView = typedMemo(function ReanimatedSizeViewComponent(props) { ++ const ctx = useStateContext(); ++ const { horizontal, signalName, style, children, ...rest } = props; ++ const [logicalSize = 0] = useArr$(signalName === "totalSize" ? [signalName] : [signalName, "totalSize"]); ++ const baseSize = useSharedValue(logicalSize); ++ const animatedDelta = useSharedValue(0); ++ const previousLogicalSizeRef = React.useRef(logicalSize); ++ const previousEpochRef = React.useRef(ctx.state.contentSizeAnimationEpoch || 0); ++ const animationRunRef = React.useRef(0); ++ const isAnimatingRef = React.useRef(false); ++ const transition = props.layoutTransition; ++ const canAnimate = !!transition && typeof transition.getAnimationAndConfig === "function"; ++ const renderEpoch = ctx.state.contentSizeAnimationEpoch || 0; ++ // Keep the animated size attached after transitions finish. Reanimated ++ // retains removed animated props, so switching to a static size leaves ++ // native scroll bounds stuck while logical measurements keep changing. ++ const animatedStyle = useAnimatedStyle(() => { ++ const size = Math.max(0, baseSize.value + animatedDelta.value); ++ return horizontal ? { width: size } : { height: size }; ++ }, [horizontal]); ++ const completeAnimation = useCallback((run, epoch) => { ++ if (animationRunRef.current !== run) { ++ return; ++ } ++ isAnimatingRef.current = false; ++ const state = ctx.state; ++ if (state.contentSizeAnimationActiveEpoch !== epoch) { ++ return; ++ } ++ state.contentSizeAnimationActiveSignals.delete(signalName); ++ if (state.contentSizeAnimationActiveSignals.size === 0) { ++ state.contentSizeAnimationActiveEpoch = void 0; ++ state.contentSizeAnimationEligible = false; ++ } ++ }, [ctx.state, signalName]); ++ React.useLayoutEffect(() => { ++ const state = ctx.state; ++ const epoch = state.contentSizeAnimationEpoch || 0; ++ const previousLogicalSize = previousLogicalSizeRef.current; ++ const delta = logicalSize - previousLogicalSize; ++ const isNewEpoch = epoch !== previousEpochRef.current; ++ // Retarget measurements received mid-animation, even in the same epoch. ++ // Updating only the base keeps the old delta and can shrink scroll bounds. ++ const shouldAnimate = canAnimate && (isAnimatingRef.current || isNewEpoch && state.contentSizeAnimationEligible); ++ previousLogicalSizeRef.current = logicalSize; ++ previousEpochRef.current = epoch; ++ if (!delta) { ++ return; ++ } ++ if (!shouldAnimate) { ++ baseSize.value = logicalSize; ++ if (signalName === "totalSize" && isNewEpoch && state.contentSizeAnimationEligible && !canAnimate) { ++ state.contentSizeAnimationEligible = false; ++ } ++ return; ++ } ++ const [animation, config] = transition.getAnimationAndConfig(); ++ const delayFunction = typeof transition.getDelayFunction === "function" ? transition.getDelayFunction() : (_, value) => value; ++ const delay = typeof transition.getDelay === "function" ? transition.getDelay() : 0; ++ if (typeof animation !== "function" || typeof delayFunction !== "function") { ++ baseSize.value = logicalSize; ++ if (signalName === "totalSize") { ++ state.contentSizeAnimationEligible = false; ++ } ++ return; ++ } ++ if (state.contentSizeAnimationActiveEpoch !== epoch) { ++ state.contentSizeAnimationActiveEpoch = epoch; ++ state.contentSizeAnimationActiveSignals = /* @__PURE__ */ new Set(); ++ } ++ state.contentSizeAnimationActiveSignals.add(signalName); ++ const wasAnimating = isAnimatingRef.current; ++ isAnimatingRef.current = true; ++ const run = ++animationRunRef.current; ++ runOnUI((absoluteSize, previousSize, continuesAnimation, animationRun, animationEpoch) => { ++ "worklet"; ++ const currentVisualSize = continuesAnimation ? baseSize.value + animatedDelta.value : previousSize; ++ baseSize.value = absoluteSize; ++ animatedDelta.value = currentVisualSize - absoluteSize; ++ animatedDelta.value = delayFunction(delay, animation(0, config, (finished) => { ++ "worklet"; ++ if (finished) { ++ runOnJS(completeAnimation)(animationRun, animationEpoch); ++ } ++ })); ++ })(logicalSize, previousLogicalSize, wasAnimating, run, epoch); ++ }, [baseSize, animatedDelta, canAnimate, completeAnimation, ctx.state, logicalSize, renderEpoch, signalName, transition]); ++ return /* @__PURE__ */ React.createElement(Reanimated.View, { ...rest, style: [style, animatedStyle] }, children); ++}); + function setSharedValueValue(sharedValue, value) { + if (!sharedValue) { + return; +@@ -221,10 +331,19 @@ var LegendListForwardedRef = typedMemo( + ); + }; + }, [hasItemLayoutAnimation, recycleItems]); ++ const sizeComponentInternal = React.useMemo(() => { ++ if (!hasItemLayoutAnimation) { ++ return void 0; ++ } ++ return function SizeComponent(sizeProps) { ++ return /* @__PURE__ */ React.createElement(ReanimatedSizeView, { ...sizeProps, layoutTransition: itemLayoutAnimationRef.current }); ++ }; ++ }, [hasItemLayoutAnimation]); + const legendListProps = { + ...rest, + positionComponentInternal, + recycleItems, ++ sizeComponentInternal, + ...{ + renderScrollComponent: renderReanimatedScrollComponent, + ...IsNewArchitecture ? { stickyPositionComponentInternal } : {} diff --git a/patches/@react-native-ai__apple@0.12.0.patch b/patches/@react-native-ai__apple@0.12.0.patch new file mode 100644 index 000000000000..b2a7aaf22991 --- /dev/null +++ b/patches/@react-native-ai__apple@0.12.0.patch @@ -0,0 +1,194 @@ +diff --git a/ios/transcription/AppleTranscriptionImpl.swift b/ios/transcription/AppleTranscriptionImpl.swift +index 188371a5f55fadae19187108c4a688569d0223e3..3e1cdd77b0bb8ae6c97a40d29c1854200d931eee 100644 +--- a/ios/transcription/AppleTranscriptionImpl.swift ++++ b/ios/transcription/AppleTranscriptionImpl.swift +@@ -12,6 +12,11 @@ import UniformTypeIdentifiers + + @objc + public class AppleTranscriptionImpl: NSObject { ++ private struct CollectedSegment: Sendable { ++ let text: String ++ let startSecond: Double ++ let endSecond: Double ++ } + + @available(iOS 26, *) + private func createTranscriber(for locale: Locale) -> SpeechTranscriber { +@@ -41,7 +46,7 @@ public class AppleTranscriptionImpl: NSObject { + let locale = Locale(identifier: language) + + guard let supportedLocale = await SpeechTranscriber.supportedLocale(equivalentTo: locale) else { +- reject("AppleTranscription", "Locale not supported: \(language)", nil) ++ reject("AppleTranscriptionUnsupportedLocale", "Locale not supported: \(language)", nil) + return + } + +@@ -51,16 +56,14 @@ public class AppleTranscriptionImpl: NSObject { + + switch status { + case .installed: +- resolve(nil) ++ resolve(supportedLocale.identifier) + case .supported, .downloading: +- if let request = try? await AssetInventory.assetInstallationRequest(supporting: [transcriber]) { ++ if let request = try await AssetInventory.assetInstallationRequest(supporting: [transcriber]) { + try await request.downloadAndInstall() +- resolve(nil) +- } else { +- resolve(nil) + } ++ resolve(supportedLocale.identifier) + case .unsupported: +- reject("AppleTranscription", "Assets not supported for locale: \(supportedLocale.identifier)", nil) ++ reject("AppleTranscriptionUnsupportedLocale", "Assets not supported for locale: \(supportedLocale.identifier)", nil) + @unknown default: + reject ("AppleTranscription", "Unknown asset inventory status", nil) + } +@@ -83,57 +86,77 @@ public class AppleTranscriptionImpl: NSObject { + do { + try audioData.write(to: fileURL) + +- guard let audioFile = try? AVAudioFile(forReading: fileURL) else { +- reject("AppleTranscription", "Invalid audio data", nil) ++ let audioFile: AVAudioFile ++ do { ++ audioFile = try AVAudioFile(forReading: fileURL) ++ } catch { ++ try? FileManager.default.removeItem(at: fileURL) ++ reject("AppleTranscription", "Invalid audio data", error) + return + } + + Task { ++ defer { ++ try? FileManager.default.removeItem(at: fileURL) ++ } ++ + do { + let transcriber = createTranscriber(for: Locale(identifier: language)) + + let analyzer = SpeechAnalyzer(modules: [transcriber]) +- +- defer { +- try? FileManager.default.removeItem(at: fileURL) +- } +- +- var segments: [[String: Any]] = [] +- +- Task { ++ ++ let collectorTask = Task { () throws -> [CollectedSegment] in ++ var segments: [CollectedSegment] = [] ++ + for try await result in transcriber.results { + if result.isFinal { +- let segment: [String: Any] = [ +- "text": String(result.text.characters), +- "startSecond": CMTimeGetSeconds(result.range.start), +- "endSecond": CMTimeGetSeconds(CMTimeRangeGetEnd(result.range)) +- ] +- segments.append(segment) ++ segments.append( ++ CollectedSegment( ++ text: String(result.text.characters), ++ startSecond: CMTimeGetSeconds(result.range.start), ++ endSecond: CMTimeGetSeconds(CMTimeRangeGetEnd(result.range)) ++ ) ++ ) + } + } ++ ++ return segments + } +- +- let lastSampleTime = try await analyzer.analyzeSequence(from: audioFile) +- +- if let lastSampleTime { +- try await analyzer.finalizeAndFinish(through: lastSampleTime) +- } else { ++ ++ do { ++ let lastSampleTime = try await analyzer.analyzeSequence(from: audioFile) ++ ++ if let lastSampleTime { ++ try await analyzer.finalizeAndFinish(through: lastSampleTime) ++ } else { ++ await analyzer.cancelAndFinishNow() ++ } ++ ++ let segments: [[String: Any]] = try await collectorTask.value.map { segment in ++ [ ++ "text": segment.text, ++ "startSecond": segment.startSecond, ++ "endSecond": segment.endSecond ++ ] ++ } ++ let totalDuration = if let lastSampleTime { CMTimeGetSeconds(lastSampleTime) } else { 0.0 } ++ ++ resolve([ ++ "segments": segments, ++ "duration": totalDuration ++ ]) ++ } catch { ++ collectorTask.cancel() + await analyzer.cancelAndFinishNow() ++ _ = try? await collectorTask.value ++ throw error + } +- +- let totalDuration = if let lastSampleTime { CMTimeGetSeconds(lastSampleTime) } else { 0.0 } +- +- let result: [String: Any] = [ +- "segments": segments, +- "duration": totalDuration +- ] +- +- resolve(result) + } catch { + reject("AppleTranscription", "Transcription failed: \(error.localizedDescription)", error) + } + } + } catch { ++ try? FileManager.default.removeItem(at: fileURL) + reject("AppleTranscription", "Failed to write audio data: \(error.localizedDescription)", error) + } + } else { +@@ -141,4 +164,3 @@ public class AppleTranscriptionImpl: NSObject { + } + } + } +- +diff --git a/lib/typescript/NativeAppleTranscription.d.ts b/lib/typescript/NativeAppleTranscription.d.ts +index 985b6b3593a884c41d689346be9d73d86c55eae1..86e7936f23a8673d099f7fe18af9a31abae5a13a 100644 +--- a/lib/typescript/NativeAppleTranscription.d.ts ++++ b/lib/typescript/NativeAppleTranscription.d.ts +@@ -10,14 +10,14 @@ export interface TranscriptionResult { + } + export interface Spec extends TurboModule { + isAvailable(language: string): boolean; +- prepare(language: string): Promise; ++ prepare(language: string): Promise; + } + declare global { + function __apple__llm__transcribe__(data: ArrayBufferLike, language: string): Promise; + } + declare const _default: { + transcribe: (data: ArrayBufferLike, language: string) => Promise; +- prepare: (language: string) => Promise; ++ prepare: (language: string) => Promise; + isAvailable: (language: string) => boolean; + }; + export default _default; +diff --git a/src/NativeAppleTranscription.ts b/src/NativeAppleTranscription.ts +index 13332a0176000b60b6f6f043de681db55a7b4bef..5389fb4b3a53de48721c307d2145b6770624c852 100644 +--- a/src/NativeAppleTranscription.ts ++++ b/src/NativeAppleTranscription.ts +@@ -14,7 +14,7 @@ export interface TranscriptionResult { + + export interface Spec extends TurboModule { + isAvailable(language: string): boolean +- prepare(language: string): Promise ++ prepare(language: string): Promise + } + + declare global { diff --git a/patches/@react-native-menu__menu@2.0.0.patch b/patches/@react-native-menu__menu@2.0.0.patch index 8794cf208eee..65e6f24a7901 100644 --- a/patches/@react-native-menu__menu@2.0.0.patch +++ b/patches/@react-native-menu__menu@2.0.0.patch @@ -1,8 +1,94 @@ +diff --git a/ios/MenuViewManager.mm b/ios/MenuViewManager.mm +index 3d149b3e7426b8f7c999603ddf1e15e12331c33a..e80fe2caae86e339fcb03408419a7d64b3e5b04f 100644 +--- a/ios/MenuViewManager.mm ++++ b/ios/MenuViewManager.mm +@@ -65,6 +65,10 @@ - (UIView *)view + * onOpenMenu: callback to be called when the menu is opened + */ + RCT_EXPORT_VIEW_PROPERTY(onOpenMenu, RCTDirectEventBlock); ++/** ++ * onMenuInteractionStart: callback to be called when UIKit starts preparing a menu interaction ++ */ ++RCT_EXPORT_VIEW_PROPERTY(onMenuInteractionStart, RCTDirectEventBlock); + /** + * shouldOpenOnLongPress: determines whether menu should be opened after long press or normal press + */ +diff --git a/ios/NewArch/FabricActionSheetView.swift b/ios/NewArch/FabricActionSheetView.swift +index 484ff8a1d73a6d445273883c5b28b1c8e80901ec..bc92310d00369b853a80f4d2080578f3903191d2 100644 +--- a/ios/NewArch/FabricActionSheetView.swift ++++ b/ios/NewArch/FabricActionSheetView.swift +@@ -3,6 +3,7 @@ public class FabricActionSheetView: ActionSheetView, FabricViewImplementationPro + public var onPressAction: ((String) -> Void)? + public var onCloseMenu: (() -> Void)? + public var onOpenMenu: (() -> Void)? ++ public var onMenuInteractionStart: (() -> Void)? + + @objc override func sendButtonAction(_ action: String) { + if let onPress = onPressAction { +diff --git a/ios/NewArch/FabricMenuViewImplementation.swift b/ios/NewArch/FabricMenuViewImplementation.swift +index 33d27c5a66593ae1a9b143804413578eb79f1a7d..359cd19fa53d10c0374b2b537517ea8629e79d0c 100644 +--- a/ios/NewArch/FabricMenuViewImplementation.swift ++++ b/ios/NewArch/FabricMenuViewImplementation.swift +@@ -12,6 +12,7 @@ public class FabricMenuViewImplementation: MenuViewImplementation, FabricViewImp + public var onPressAction: ((String) -> Void)? + public var onCloseMenu: (() -> Void)? + public var onOpenMenu: (() -> Void)? ++ public var onMenuInteractionStart: (() -> Void)? + + @objc override func sendButtonAction(_ action: UIAction) { + if let onPress = onPressAction { +@@ -31,4 +32,10 @@ public class FabricMenuViewImplementation: MenuViewImplementation, FabricViewImp + } + } + ++ @objc override func sendMenuInteractionStart() { ++ if let onMenuInteractionStart = onMenuInteractionStart { ++ onMenuInteractionStart() ++ } ++ } ++ + } +diff --git a/ios/NewArch/FabricViewImplementationProtocol.swift b/ios/NewArch/FabricViewImplementationProtocol.swift +index 2cef2fb8c74497da46775a490edc58cf8cbf29cc..54429c756eeecf6e96b55d62cc1e70213096f62a 100644 +--- a/ios/NewArch/FabricViewImplementationProtocol.swift ++++ b/ios/NewArch/FabricViewImplementationProtocol.swift +@@ -8,4 +8,5 @@ import Foundation + var onPressAction: ((String) -> Void)? { get set } + var onCloseMenu: (() -> Void)? { get set } + var onOpenMenu: (() -> Void)? { get set } ++ var onMenuInteractionStart: (() -> Void)? { get set } + } diff --git a/ios/NewArch/MenuView.mm b/ios/NewArch/MenuView.mm -index a54e619eb39e3402fa63f2ee4f734063d4f8fc58..066b01259a3c8ad9532688606be3d29a94a8f7d4 100644 +index a54e619eb39e3402fa63f2ee4f734063d4f8fc58..773f6f1f5d4e0663fd3211fe51085a7f3935e445 100644 --- a/ios/NewArch/MenuView.mm +++ b/ios/NewArch/MenuView.mm -@@ -105,6 +105,27 @@ - (void)onOpenMenu { +@@ -46,6 +46,10 @@ - (instancetype)initWithFrame:(CGRect)frame + [self onOpenMenu]; + }; + ++ _view.onMenuInteractionStart = ^{ ++ [self onMenuInteractionStart]; ++ }; ++ + _view.onCloseMenu = ^{ + [self onCloseMenu]; + }; +@@ -92,6 +96,14 @@ - (void)onOpenMenu { + } + } + ++- (void)onMenuInteractionStart { ++ // If screen is already unmounted then there will be no event emitter ++ const auto eventEmitter = [self getEventEmitter]; ++ if (eventEmitter != nullptr) { ++ eventEmitter->onMenuInteractionStart({}); ++ } ++} ++ + /** + Responsible for iterating through the C++ vector and convert each struct element to NSDictionary, then return it all in an NSArray + */ +@@ -105,6 +117,27 @@ - (void)onOpenMenu { NSMutableArray *subactionsArray = [NSMutableArray arrayWithCapacity:actions.size()]; if (action.subactions.size() > 0) { for (const MenuViewActionsSubactionsStruct &subaction : action.subactions) { @@ -30,7 +116,7 @@ index a54e619eb39e3402fa63f2ee4f734063d4f8fc58..066b01259a3c8ad9532688606be3d29a NSDictionary *subactionDict = @{ @"id": [NSString stringWithUTF8String:subaction.id.c_str()], @"title": [NSString stringWithUTF8String:subaction.title.c_str()], -@@ -118,7 +139,9 @@ - (void)onOpenMenu { +@@ -118,7 +151,9 @@ - (void)onOpenMenu { @"destructive": @(subaction.attributes.destructive), @"disabled": @(subaction.attributes.disabled), @"hidden": @(subaction.attributes.hidden), @@ -40,7 +126,7 @@ index a54e619eb39e3402fa63f2ee4f734063d4f8fc58..066b01259a3c8ad9532688606be3d29a }; [subactionsArray addObject:subactionDict]; } -@@ -138,6 +161,7 @@ - (void)onOpenMenu { +@@ -138,6 +173,7 @@ - (void)onOpenMenu { @"destructive": @(action.attributes.destructive), @"disabled": @(action.attributes.disabled), @"hidden": @(action.attributes.hidden), @@ -48,20 +134,50 @@ index a54e619eb39e3402fa63f2ee4f734063d4f8fc58..066b01259a3c8ad9532688606be3d29a }, @"subactions": subactionsArray, }; +diff --git a/ios/OldArch/LegacyActionSheetView.swift b/ios/OldArch/LegacyActionSheetView.swift +index 3cd1a2517624d99b51331c564fba9145d7c8d799..e458504bea6ef167d9b0db0c889f0f76ea965287 100644 +--- a/ios/OldArch/LegacyActionSheetView.swift ++++ b/ios/OldArch/LegacyActionSheetView.swift +@@ -3,6 +3,7 @@ public class LegacyActionSheetView: ActionSheetView { + @objc var onPressAction: RCTDirectEventBlock? + @objc var onCloseMenu: RCTDirectEventBlock? + @objc var onOpenMenu: RCTDirectEventBlock? ++ @objc var onMenuInteractionStart: RCTDirectEventBlock? + + + +diff --git a/ios/OldArch/LegacyMenuViewImplementation.swift b/ios/OldArch/LegacyMenuViewImplementation.swift +index 6a2a205f95c1001f3fdf6fb835815fa7c4c02c16..4f42b64349966fb1438f72439cec8256a76aab9c 100644 +--- a/ios/OldArch/LegacyMenuViewImplementation.swift ++++ b/ios/OldArch/LegacyMenuViewImplementation.swift +@@ -5,6 +5,7 @@ public class LegacyMenuViewImplementation: MenuViewImplementation { + @objc var onPressAction: RCTDirectEventBlock? + @objc var onCloseMenu: RCTDirectEventBlock? + @objc var onOpenMenu: RCTDirectEventBlock? ++ @objc var onMenuInteractionStart: RCTDirectEventBlock? + + @objc override func sendButtonAction(_ action: UIAction) { + if let onPress = onPressAction { +@@ -24,4 +25,10 @@ public class LegacyMenuViewImplementation: MenuViewImplementation { + } + } + ++ @objc override func sendMenuInteractionStart() { ++ if let onMenuInteractionStart = onMenuInteractionStart { ++ onMenuInteractionStart([:]) ++ } ++ } ++ + } diff --git a/ios/Shared/MenuViewImplementation.swift b/ios/Shared/MenuViewImplementation.swift -index 5c4e0da4292b15d3a27b5ea1555f11452a470815..db134864676ed83dbcd895d7a1bde38e8037a005 100644 +index 5c4e0da4292b15d3a27b5ea1555f11452a470815..4d8abe3c4fc45bda48c2958bc9ca933c77f2cc47 100644 --- a/ios/Shared/MenuViewImplementation.swift +++ b/ios/Shared/MenuViewImplementation.swift -@@ -59,18 +59,43 @@ public class MenuViewImplementation: UIButton { - self.setup() +@@ -60,17 +60,46 @@ public class MenuViewImplementation: UIButton { } -+ // Presentation is tracked from the two delegate methods the class already -+ // overrode. Overriding willDisplayMenuFor as well (even for bookkeeping) -+ // shadows UIButton's own implementation and degrades the button-anchored -+ // presentation into generic context-menu chrome — an empty header row with -+ // a dismiss chevron appears above the actions. public override func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? { +- sendMenuOpen() + // Flush updates deferred by the presented-guard before the action + // provider snapshots self.menu (covers a stuck flag from an + // interaction that never ended cleanly). @@ -71,15 +187,24 @@ index 5c4e0da4292b15d3a27b5ea1555f11452a470815..db134864676ed83dbcd895d7a1bde38e + self.setup() + } + isMenuPresented = true - sendMenuOpen() ++ sendMenuInteractionStart() return UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { [weak self] _ in guard let self = self else { return nil } return self.menu } } - ++ ++ public override func contextMenuInteraction(_ interaction: UIContextMenuInteraction, willDisplayMenuFor configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionAnimating?) { ++ // UIKit can request a configuration for a press that never opens a ++ // menu. Notify React only once presentation actually begins, and ++ // preserve UIButton's presentation behavior by calling super. ++ super.contextMenuInteraction(interaction, willDisplayMenuFor: configuration, animator: animator) ++ sendMenuOpen() ++ } + public override func contextMenuInteraction(_ interaction: UIContextMenuInteraction, willEndFor configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionAnimating?) { ++ super.contextMenuInteraction(interaction, willEndFor: configuration, animator: animator) sendMenuClose() + isMenuPresented = false + if pendingMenu != nil { @@ -97,7 +222,7 @@ index 5c4e0da4292b15d3a27b5ea1555f11452a470815..db134864676ed83dbcd895d7a1bde38e func setup () { let menu = UIMenu(title: _title, identifier: nil, -@@ -86,8 +111,98 @@ public class MenuViewImplementation: UIButton { +@@ -86,8 +115,98 @@ public class MenuViewImplementation: UIButton { } } @@ -196,6 +321,15 @@ index 5c4e0da4292b15d3a27b5ea1555f11452a470815..db134864676ed83dbcd895d7a1bde38e } public override func reactSetFrame(_ frame: CGRect) { +@@ -124,4 +243,8 @@ public class MenuViewImplementation: UIButton { + @objc func sendMenuOpen() { + // NO-OP (should be overriden by parent) + } ++ ++ @objc func sendMenuInteractionStart() { ++ // NO-OP (should be overriden by parent) ++ } + } diff --git a/ios/Shared/RCTMenuItem.swift b/ios/Shared/RCTMenuItem.swift index bb6bb2b7ad56135089f267587c974b166760539d..949b3f7ec49af7ba26d966a8923a51f619443d5a 100644 --- a/ios/Shared/RCTMenuItem.swift @@ -220,8 +354,83 @@ index bb6bb2b7ad56135089f267587c974b166760539d..949b3f7ec49af7ba26d966a8923a51f6 } if #available(iOS 16.0, *) { +diff --git a/lib/commonjs/NativeModuleSpecs/UIMenuNativeComponent.ts b/lib/commonjs/NativeModuleSpecs/UIMenuNativeComponent.ts +index e6509355275596c451f9d082223cc16bfe504e1a..dd16e1a7403970cc3a404355f95ed99580776353 100644 +--- a/lib/commonjs/NativeModuleSpecs/UIMenuNativeComponent.ts ++++ b/lib/commonjs/NativeModuleSpecs/UIMenuNativeComponent.ts +@@ -48,6 +48,7 @@ export interface NativeProps extends ViewProps { + onPressAction?: DirectEventHandler<{ event: string }>; + onCloseMenu?: DirectEventHandler<{ event: string }>; + onOpenMenu?: DirectEventHandler<{ event: string }>; ++ onMenuInteractionStart?: DirectEventHandler<{ event: string }>; + actions: Array; + actionsHash: string; // just a workaround to make sure we don't have to manually compare MenuActions manually in C++ (since it's a struct and that's a pain) + title?: string; +diff --git a/lib/module/NativeModuleSpecs/UIMenuNativeComponent.ts b/lib/module/NativeModuleSpecs/UIMenuNativeComponent.ts +index e6509355275596c451f9d082223cc16bfe504e1a..dd16e1a7403970cc3a404355f95ed99580776353 100644 +--- a/lib/module/NativeModuleSpecs/UIMenuNativeComponent.ts ++++ b/lib/module/NativeModuleSpecs/UIMenuNativeComponent.ts +@@ -48,6 +48,7 @@ export interface NativeProps extends ViewProps { + onPressAction?: DirectEventHandler<{ event: string }>; + onCloseMenu?: DirectEventHandler<{ event: string }>; + onOpenMenu?: DirectEventHandler<{ event: string }>; ++ onMenuInteractionStart?: DirectEventHandler<{ event: string }>; + actions: Array; + actionsHash: string; // just a workaround to make sure we don't have to manually compare MenuActions manually in C++ (since it's a struct and that's a pain) + title?: string; +diff --git a/lib/typescript/src/NativeModuleSpecs/UIMenuNativeComponent.d.ts b/lib/typescript/src/NativeModuleSpecs/UIMenuNativeComponent.d.ts +index 24ee7fdcd9f81cfe2c813cfca9d4e276b846fb7b..5ee7b45bafea8547d68949aaba70584055f9fb53 100644 +--- a/lib/typescript/src/NativeModuleSpecs/UIMenuNativeComponent.d.ts ++++ b/lib/typescript/src/NativeModuleSpecs/UIMenuNativeComponent.d.ts +@@ -41,6 +41,9 @@ export interface NativeProps extends ViewProps { + onOpenMenu?: DirectEventHandler<{ + event: string; + }>; ++ onMenuInteractionStart?: DirectEventHandler<{ ++ event: string; ++ }>; + actions: Array; + actionsHash: string; + title?: string; +diff --git a/lib/typescript/src/index.d.ts b/lib/typescript/src/index.d.ts +index 61d210aa9364f0354f77bddaecdc0a127b1cdbb4..84ac2bc8b4edac045ed59896d5eb4e6eaef4725d 100644 +--- a/lib/typescript/src/index.d.ts ++++ b/lib/typescript/src/index.d.ts +@@ -4,6 +4,7 @@ declare const MenuView: import("react").ForwardRefExoticComponent<{ + onPressAction?: ({ nativeEvent }: NativeActionEvent) => void; + onCloseMenu?: () => void; + onOpenMenu?: () => void; ++ onMenuInteractionStart?: () => void; + actions: MenuAction[]; + title?: string; + isAnchoredToRight?: boolean; +diff --git a/lib/typescript/src/types.d.ts b/lib/typescript/src/types.d.ts +index eac7940ab7eb6a69ac329848c9f3eeffdcbaf43d..271cd27f777be06bc02f05d4b4dac5bb940f5f0f 100644 +--- a/lib/typescript/src/types.d.ts ++++ b/lib/typescript/src/types.d.ts +@@ -113,6 +113,12 @@ type MenuComponentPropsBase = { + * Callback function that will be called when the menu opens. + */ + onOpenMenu?: () => void; ++ /** ++ * Callback function that will be called when UIKit starts preparing a menu interaction. ++ * Preparation can end without the menu opening. ++ * @platform iOS ++ */ ++ onMenuInteractionStart?: () => void; + /** + * Actions to be displayed in the menu. + */ +@@ -170,6 +176,7 @@ export type NativeMenuComponentProps = { + onPressAction?: ({ nativeEvent }: NativeActionEvent) => void; + onCloseMenu?: () => void; + onOpenMenu?: () => void; ++ onMenuInteractionStart?: () => void; + actions: ProcessedMenuAction[]; + actionsHash: string; + title?: string; diff --git a/src/NativeModuleSpecs/UIMenuNativeComponent.ts b/src/NativeModuleSpecs/UIMenuNativeComponent.ts -index e6509355275596c451f9d082223cc16bfe504e1a..c783cdaf73f5635cf9835824ca85fba3b46453b4 100644 +index e6509355275596c451f9d082223cc16bfe504e1a..604dba0d90a2545910f8724a80205565de2391d4 100644 --- a/src/NativeModuleSpecs/UIMenuNativeComponent.ts +++ b/src/NativeModuleSpecs/UIMenuNativeComponent.ts @@ -13,6 +13,22 @@ import codegenNativeComponent from "react-native/Libraries/Utilities/codegenNati @@ -267,3 +476,36 @@ index e6509355275596c451f9d082223cc16bfe504e1a..c783cdaf73f5635cf9835824ca85fba3 }; subactions?: Array; }; +@@ -48,6 +69,7 @@ export interface NativeProps extends ViewProps { + onPressAction?: DirectEventHandler<{ event: string }>; + onCloseMenu?: DirectEventHandler<{ event: string }>; + onOpenMenu?: DirectEventHandler<{ event: string }>; ++ onMenuInteractionStart?: DirectEventHandler<{ event: string }>; + actions: Array; + actionsHash: string; // just a workaround to make sure we don't have to manually compare MenuActions manually in C++ (since it's a struct and that's a pain) + title?: string; +diff --git a/src/types.ts b/src/types.ts +index 9c323d9c510e7359c83d4d6633affde673570632..5d5fdfb8b8235fcaa8295b85630443eaf5991a17 100644 +--- a/src/types.ts ++++ b/src/types.ts +@@ -123,6 +123,12 @@ type MenuComponentPropsBase = { + * Callback function that will be called when the menu opens. + */ + onOpenMenu?: () => void; ++ /** ++ * Callback function that will be called when UIKit starts preparing a menu interaction. ++ * Preparation can end without the menu opening. ++ * @platform iOS ++ */ ++ onMenuInteractionStart?: () => void; + /** + * Actions to be displayed in the menu. + */ +@@ -189,6 +195,7 @@ export type NativeMenuComponentProps = { + onPressAction?: ({ nativeEvent }: NativeActionEvent) => void; + onCloseMenu?: () => void; + onOpenMenu?: () => void; ++ onMenuInteractionStart?: () => void; + actions: ProcessedMenuAction[]; + actionsHash: string; + title?: string; diff --git a/patches/@react-native__gradle-plugin@0.85.3.patch b/patches/@react-native__gradle-plugin@0.85.3.patch deleted file mode 100644 index 7106ccca4ac3..000000000000 --- a/patches/@react-native__gradle-plugin@0.85.3.patch +++ /dev/null @@ -1,13 +0,0 @@ -diff --git a/settings.gradle.kts b/settings.gradle.kts -index b3c46a3bf5eb21c0bb1c9435b5582aecbc57d57a..14f1e9c2063ef10ca3f2ecbb2336b8a4aacfcc9c 100644 ---- a/settings.gradle.kts -+++ b/settings.gradle.kts -@@ -13,7 +13,7 @@ pluginManagement { - } - } - --plugins { id("org.gradle.toolchains.foojay-resolver-convention").version("0.5.0") } -+plugins { id("org.gradle.toolchains.foojay-resolver-convention").version("1.0.0") } - - include( - ":react-native-gradle-plugin", diff --git a/patches/@react-navigation%2Fnative-stack@7.17.6.patch b/patches/@react-navigation%2Fnative-stack@7.17.6.patch index e92ae4975631..d2c6bc918881 100644 --- a/patches/@react-navigation%2Fnative-stack@7.17.6.patch +++ b/patches/@react-navigation%2Fnative-stack@7.17.6.patch @@ -1,8 +1,30 @@ +diff --git a/lib/module/views/NativeStackView.native.js b/lib/module/views/NativeStackView.native.js +index c342e90bffde9ad5044b6167bdde2e19be219270..5d3e4407aa1bb68b873cf164adf5a9f0b487bb0b 100644 +--- a/lib/module/views/NativeStackView.native.js ++++ b/lib/module/views/NativeStackView.native.js +@@ -370,6 +370,17 @@ export function NativeStackView({ + return /*#__PURE__*/_jsx(SafeAreaProviderCompat, { + children: /*#__PURE__*/_jsx(ScreenStack, { + style: styles.container, ++ onFinishTransitioning: () => { ++ // Surface UIKit's transition-completion callback to every route of ++ // this navigator. Unlike transitionEnd, this also fires when a modal ++ // finishes dismissing — where the presenting screen below receives no ++ // appearance callbacks — and for a gesture-driven dismissal it fires ++ // before the state pop, while the modal route is still the focused ++ // one, so the event must not be targeted at a single route. ++ navigation.emit({ ++ type: 'finishTransitioning' ++ }); ++ }, + children: state.routes.concat(state.preloadedRoutes).map((route, index) => { + const descriptor = descriptors[route.key] ?? preloadedDescriptors[route.key]; + const isFocused = state.index === index; diff --git a/lib/module/views/useHeaderConfigProps.js b/lib/module/views/useHeaderConfigProps.js -index 0b75c70b4e0d233ee3b5faaf9cfbc40d4f8ed494..eb174e3fde91a7783f132b3fb16b01175ec19220 100644 +index 9c21aa48cb580704aa17e59d9cdadcc612a1e4ff..a1b3a38368c5ef2257570f382e87df5d6f55b11e 100644 --- a/lib/module/views/useHeaderConfigProps.js +++ b/lib/module/views/useHeaderConfigProps.js -@@ -19,6 +19,12 @@ +@@ -19,6 +19,12 @@ const processBarButtonItems = (items, colors, fonts) => { } return item; } @@ -15,7 +37,7 @@ index 0b75c70b4e0d233ee3b5faaf9cfbc40d4f8ed494..eb174e3fde91a7783f132b3fb16b0117 if (item.type === 'button' || item.type === 'menu') { if (item.type === 'menu' && item.menu == null) { throw new Error(`Menu item must have a 'menu' property defined: ${JSON.stringify(item)}`); -@@ -79,7 +85,7 @@ +@@ -79,7 +85,7 @@ const processBarButtonItems = (items, colors, fonts) => { } return processedItem; } @@ -24,7 +46,7 @@ index 0b75c70b4e0d233ee3b5faaf9cfbc40d4f8ed494..eb174e3fde91a7783f132b3fb16b0117 }).filter(item => item != null); }; const transformIcon = icon => { -@@ -158,8 +164,12 @@ +@@ -158,8 +164,12 @@ export function useHeaderConfigProps({ headerBack, route, title, @@ -38,7 +60,7 @@ index 0b75c70b4e0d233ee3b5faaf9cfbc40d4f8ed494..eb174e3fde91a7783f132b3fb16b0117 }) { const { direction -@@ -255,6 +265,10 @@ +@@ -255,6 +265,10 @@ export function useHeaderConfigProps({ tintColor, canGoBack }); @@ -49,7 +71,7 @@ index 0b75c70b4e0d233ee3b5faaf9cfbc40d4f8ed494..eb174e3fde91a7783f132b3fb16b0117 let rightItems = headerRightItems?.({ tintColor, canGoBack -@@ -264,6 +278,10 @@ +@@ -264,6 +278,10 @@ export function useHeaderConfigProps({ // So we need to reverse them here to match the order rightItems = [...rightItems].reverse(); } @@ -60,7 +82,14 @@ index 0b75c70b4e0d233ee3b5faaf9cfbc40d4f8ed494..eb174e3fde91a7783f132b3fb16b0117 const children = /*#__PURE__*/_jsxs(_Fragment, { children: [Platform.OS === 'ios' ? /*#__PURE__*/_jsxs(_Fragment, { children: [leftItems ? leftItems.map((item, index) => { -@@ -278,7 +296,15 @@ +@@ -272,13 +290,23 @@ export function useHeaderConfigProps({ + // eslint-disable-next-line @eslint-react/no-array-index-key + , { + hidesSharedBackground: item.hidesSharedBackground, ++ identifier: item.identifier, + children: item.element + }, index); + } return null; }) : headerLeftElement != null ? /*#__PURE__*/_jsx(ScreenStackHeaderLeftView, { children: headerLeftElement @@ -69,6 +98,7 @@ index 0b75c70b4e0d233ee3b5faaf9cfbc40d4f8ed494..eb174e3fde91a7783f132b3fb16b0117 + if (item.type === 'custom') { + return /*#__PURE__*/_jsx(ScreenStackHeaderCenterView, { + hidesSharedBackground: item.hidesSharedBackground, ++ identifier: item.identifier, + children: item.element + }, index); + } @@ -77,7 +107,15 @@ index 0b75c70b4e0d233ee3b5faaf9cfbc40d4f8ed494..eb174e3fde91a7783f132b3fb16b0117 children: headerTitleElement }) : null] }) : /*#__PURE__*/_jsxs(_Fragment, { -@@ -356,6 +382,7 @@ +@@ -321,6 +349,7 @@ export function useHeaderConfigProps({ + // eslint-disable-next-line @eslint-react/no-array-index-key + , { + hidesSharedBackground: item.hidesSharedBackground, ++ identifier: item.identifier, + children: item.element + }, index); + } +@@ -356,6 +385,7 @@ export function useHeaderConfigProps({ largeTitleFontWeight, largeTitleHideShadow: headerLargeTitleShadowVisible === false, title: titleText, @@ -85,7 +123,7 @@ index 0b75c70b4e0d233ee3b5faaf9cfbc40d4f8ed494..eb174e3fde91a7783f132b3fb16b0117 titleColor, titleFontFamily, titleFontSize, -@@ -364,9 +391,12 @@ +@@ -364,6 +394,9 @@ export function useHeaderConfigProps({ disableTopInsetApplication: !headerTopInsetEnabled, translucent: translucent === true, children, @@ -95,30 +133,59 @@ index 0b75c70b4e0d233ee3b5faaf9cfbc40d4f8ed494..eb174e3fde91a7783f132b3fb16b0117 headerLeftBarButtonItems: processBarButtonItems(leftItems, colors, fonts), headerRightBarButtonItems: processBarButtonItems(rightItems, colors, fonts), experimental_userInterfaceStyle: dark ? 'dark' : 'light' - }; - } --//# sourceMappingURL=useHeaderConfigProps.js.map -\ No newline at end of file -+//# sourceMappingURL=useHeaderConfigProps.js.map -diff --git a/lib/module/views/NativeStackView.native.js b/lib/module/views/NativeStackView.native.js -index c342e90..5d3e440 100644 ---- a/lib/module/views/NativeStackView.native.js -+++ b/lib/module/views/NativeStackView.native.js -@@ -370,6 +370,17 @@ export function NativeStackView({ - return /*#__PURE__*/_jsx(SafeAreaProviderCompat, { - children: /*#__PURE__*/_jsx(ScreenStack, { - style: styles.container, -+ onFinishTransitioning: () => { -+ // Surface UIKit's transition-completion callback to every route of -+ // this navigator. Unlike transitionEnd, this also fires when a modal -+ // finishes dismissing — where the presenting screen below receives no -+ // appearance callbacks — and for a gesture-driven dismissal it fires -+ // before the state pop, while the modal route is still the focused -+ // one, so the event must not be targeted at a single route. -+ navigation.emit({ -+ type: 'finishTransitioning' -+ }); -+ }, - children: state.routes.concat(state.preloadedRoutes).map((route, index) => { - const descriptor = descriptors[route.key] ?? preloadedDescriptors[route.key]; - const isFocused = state.index === index; +diff --git a/lib/typescript/src/types.d.ts b/lib/typescript/src/types.d.ts +index 2f1351a89f0f2e67e854dc81e392198326b71834..ccf8556911fa1fa17588f06b56edb2151a712bf0 100644 +--- a/lib/typescript/src/types.d.ts ++++ b/lib/typescript/src/types.d.ts +@@ -1091,6 +1091,13 @@ export type NativeStackHeaderItemSpacing = { + */ + export type NativeStackHeaderItemCustom = { + type: 'custom'; ++ /** ++ * An identifier used to match items across transitions. ++ * Only available from iOS 26.0 and later. ++ * ++ * Read more: https://developer.apple.com/documentation/uikit/uibarbuttonitem/identifier ++ */ ++ identifier?: string; + /** + * A React Element to display as the item. + */ +diff --git a/src/types.tsx b/src/types.tsx +index 7488b1cf20afe36b5c585fbfcd369471fdb6ab8d..f4bd78e48c687024ba84e998fac79e9eb16f1a80 100644 +--- a/src/types.tsx ++++ b/src/types.tsx +@@ -1156,6 +1156,13 @@ export type NativeStackHeaderItemSpacing = { + */ + export type NativeStackHeaderItemCustom = { + type: 'custom'; ++ /** ++ * An identifier used to match items across transitions. ++ * Only available from iOS 26.0 and later. ++ * ++ * Read more: https://developer.apple.com/documentation/uikit/uibarbuttonitem/identifier ++ */ ++ identifier?: string; + /** + * A React Element to display as the item. + */ +diff --git a/src/views/useHeaderConfigProps.tsx b/src/views/useHeaderConfigProps.tsx +index 3f3fff405048b3c2250afbe99dd7f35cecb9a6d5..bb062b898cfa77f8c02f951dc9d81b51672d7d0e 100644 +--- a/src/views/useHeaderConfigProps.tsx ++++ b/src/views/useHeaderConfigProps.tsx +@@ -397,6 +397,7 @@ export function useHeaderConfigProps({ + // eslint-disable-next-line @eslint-react/no-array-index-key + key={index} + hidesSharedBackground={item.hidesSharedBackground} ++ identifier={item.identifier} + > + {item.element} + +@@ -471,6 +472,7 @@ export function useHeaderConfigProps({ + // eslint-disable-next-line @eslint-react/no-array-index-key + key={index} + hidesSharedBackground={item.hidesSharedBackground} ++ identifier={item.identifier} + > + {item.element} + diff --git a/patches/expo-audio@57.0.4.patch b/patches/expo-audio@57.0.4.patch new file mode 100644 index 000000000000..1452eca31c15 --- /dev/null +++ b/patches/expo-audio@57.0.4.patch @@ -0,0 +1,24 @@ +diff --git a/ios/AudioRecorder.swift b/ios/AudioRecorder.swift +index 20020bff9f8ba7bb6e7f61c99b4de5d29eee00db..284c22f12e30c8c2ed16feb141876cbfd8897c90 100644 +--- a/ios/AudioRecorder.swift ++++ b/ios/AudioRecorder.swift +@@ -216,15 +216,15 @@ class AudioRecorder: SharedRef, RecordingResultHandler { + } + + func didFinish(_ recorder: AVAudioRecorder, successfully flag: Bool) { +- // Update internal state when recording finishes automatically (e.g., from recordForDuration) +- currentState = .stopped ++ // Update internal state when AVAudioRecorder finishes or fails. ++ currentState = flag ? .stopped : .error + resetDurationTracking() + + emit(event: recordingStatus, payload: [ + "id": id, + "isFinished": true, +- "hasError": false, +- "error": nil, ++ "hasError": !flag, ++ "error": flag ? nil : "Recording failed", + "url": recorder.url.absoluteString + ]) + } diff --git a/patches/expo-modules-jsi@56.0.10.patch b/patches/expo-modules-jsi@56.0.10.patch deleted file mode 100644 index afb1da5caaa0..000000000000 --- a/patches/expo-modules-jsi@56.0.10.patch +++ /dev/null @@ -1,21 +0,0 @@ -diff --git a/apple/Sources/ExpoModulesJSI/Runtime/JavaScriptRuntime.swift b/apple/Sources/ExpoModulesJSI/Runtime/JavaScriptRuntime.swift -index a0e3e24d6070c5ec0a220af3cb56fe3373fe1968..d610cc38dd8dee363ea2f1822aea0c4a492cf825 100644 ---- a/apple/Sources/ExpoModulesJSI/Runtime/JavaScriptRuntime.swift -+++ b/apple/Sources/ExpoModulesJSI/Runtime/JavaScriptRuntime.swift -@@ -215,8 +215,15 @@ open class JavaScriptRuntime: Equatable, @unchecked Sendable { - .toOpaque() - // Pass a null setter to C++ when the Swift setter is nil so that JS assignment - // raises a `jsi::JSError` directly, without crossing the Swift boundary. -+ if set == nil { -+ let callbacks = expo.HostObjectCallbacks( -+ context, getter, nil, propertyNamesGetter, deallocate) -+ let hostObject = expo.HostObject.makeObject(pointee, consume callbacks) -+ -+ return JavaScriptObject(self, hostObject) -+ } - let callbacks = expo.HostObjectCallbacks( -- context, getter, set == nil ? nil : setter, propertyNamesGetter, deallocate) -+ context, getter, setter, propertyNamesGetter, deallocate) - let hostObject = expo.HostObject.makeObject(pointee, consume callbacks) - - return JavaScriptObject(self, hostObject) diff --git a/patches/expo-sharing@57.0.16.patch b/patches/expo-sharing@57.0.16.patch new file mode 100644 index 000000000000..924df6c8c7c1 --- /dev/null +++ b/patches/expo-sharing@57.0.16.patch @@ -0,0 +1,123 @@ +diff --git a/android/src/main/java/expo/modules/sharing/SharingRecords.kt b/android/src/main/java/expo/modules/sharing/SharingRecords.kt +index 0b87b53f632e82f9b4c09c066d5a67e4ab2525a4..65f1c57faef3ba8caac9e137341f2b8312bb5f86 100644 +--- a/android/src/main/java/expo/modules/sharing/SharingRecords.kt ++++ b/android/src/main/java/expo/modules/sharing/SharingRecords.kt +@@ -52,7 +52,8 @@ enum class ContentType(val value: String) : Enumerable { + internal data class SharePayload( + @Field var value: String = "", + @Field var shareType: ShareType = ShareType.Text, +- @Field var mimeType: String = "text/plain" ++ @Field var mimeType: String = "text/plain", ++ @Field var originalName: String? = null + ) : Record + + @OptimizedRecord +diff --git a/android/src/main/java/expo/modules/sharing/dataParsers/SimpleShareIntentDataParser.kt b/android/src/main/java/expo/modules/sharing/dataParsers/SimpleShareIntentDataParser.kt +index 8dd50e8f04022d183884a57e4d61a7e1711b9e49..481cdb4bc64ea5ae49919071f686324f6d56d55c 100644 +--- a/android/src/main/java/expo/modules/sharing/dataParsers/SimpleShareIntentDataParser.kt ++++ b/android/src/main/java/expo/modules/sharing/dataParsers/SimpleShareIntentDataParser.kt +@@ -3,6 +3,7 @@ package expo.modules.sharing + import android.content.Context + import android.content.Intent + import android.net.Uri ++import android.provider.OpenableColumns + + internal class SimpleShareIntentDataParser { + companion object { +@@ -17,21 +18,26 @@ internal class SimpleShareIntentDataParser { + } + + private fun handleSendAction(context: Context, intent: Intent, type: String): List { +- return if (type == "text/plain") { +- val text = intent.getStringExtra(Intent.EXTRA_TEXT) ?: return emptyList() +- val isUrl = android.util.Patterns.WEB_URL.matcher(text).matches() +- +- listOf( ++ val stream = intent.getParcelableExtraCompat(Intent.EXTRA_STREAM) ++ val text = intent.getStringExtra(Intent.EXTRA_TEXT) ++ if (stream == null) { ++ if (!type.startsWith("text/") || text == null) return emptyList() ++ return listOf( + SharePayload().apply { + value = text +- shareType = if (isUrl) ShareType.Url else ShareType.Text +- mimeType = "text/plain" ++ shareType = if (android.util.Patterns.WEB_URL.matcher(text).matches()) ShareType.Url else ShareType.Text ++ mimeType = type + } + ) +- } else { +- val uri = intent.getParcelableExtraCompat(Intent.EXTRA_STREAM) +- listOfNotNull(uri?.let { createUriPayload(context, it, type) }) + } ++ val filePayload = createUriPayload(context, stream, type) ++ if (text.isNullOrBlank()) return listOf(filePayload) ++ val textPayload = SharePayload().apply { ++ value = text ++ shareType = if (android.util.Patterns.WEB_URL.matcher(text).matches()) ShareType.Url else ShareType.Text ++ mimeType = "text/plain" ++ } ++ return listOf(textPayload, filePayload) + } + + private fun handleSendMultipleAction(context: Context, intent: Intent, type: String): List { +@@ -41,10 +47,25 @@ internal class SimpleShareIntentDataParser { + + private fun createUriPayload(context: Context, uri: Uri, defaultType: String): SharePayload { + val specificType = context.contentResolver.getType(uri) ?: defaultType ++ // A blank DISPLAY_NAME must stay null: consumers treat originalName as ++ // the attachment name, and an empty name fails contract validation. ++ val displayName = runCatching { ++ context.contentResolver.query( ++ uri, ++ arrayOf(OpenableColumns.DISPLAY_NAME), ++ null, ++ null, ++ null ++ )?.use { cursor -> ++ val column = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) ++ if (column >= 0 && cursor.moveToFirst()) cursor.getString(column) else null ++ } ++ }.getOrNull()?.takeIf { it.isNotBlank() } + return SharePayload().apply { + value = uri.toString() +- shareType = ShareType.fromMimeType(specificType) ++ shareType = if (specificType.startsWith("text/")) ShareType.File else ShareType.fromMimeType(specificType) + mimeType = specificType ++ originalName = displayName + } + } + } +diff --git a/build/Sharing.types.d.ts b/build/Sharing.types.d.ts +index e9820b061bddce91657ab7144fde98d0543a3b8e..364d63273cb3e209c6490de6dd869a01c8728fcc 100644 +--- a/build/Sharing.types.d.ts ++++ b/build/Sharing.types.d.ts +@@ -77,6 +77,11 @@ export type SharePayload = { + * @default 'text/plain' + */ + mimeType?: string; ++ /** ++ * The display name of a shared file, when the source app provided one. ++ * Populated on Android from the content resolver's `DISPLAY_NAME`. ++ */ ++ originalName?: string | null; + }; + export type BaseResolvedSharePayload = SharePayload & { + /** +diff --git a/src/Sharing.types.ts b/src/Sharing.types.ts +index d9a5ad38f5fe2856b365eb91b71abd640314a5cc..21ea45f04b88cce272e2e02de24c5e4eb8157e82 100644 +--- a/src/Sharing.types.ts ++++ b/src/Sharing.types.ts +@@ -85,6 +85,12 @@ export type SharePayload = { + * @default 'text/plain' + */ + mimeType?: string; ++ ++ /** ++ * The display name of a shared file, when the source app provided one. ++ * Populated on Android from the content resolver's `DISPLAY_NAME`. ++ */ ++ originalName?: string | null; + }; + + export type BaseResolvedSharePayload = SharePayload & { diff --git a/patches/react-native-gesture-handler@2.31.2.patch b/patches/react-native-gesture-handler@2.32.0.patch similarity index 100% rename from patches/react-native-gesture-handler@2.31.2.patch rename to patches/react-native-gesture-handler@2.32.0.patch diff --git a/patches/react-native-keyboard-controller@1.21.13.patch b/patches/react-native-keyboard-controller@1.21.13.patch index 3dee935cda33..f2e510a940bf 100644 --- a/patches/react-native-keyboard-controller@1.21.13.patch +++ b/patches/react-native-keyboard-controller@1.21.13.patch @@ -1,12 +1,13 @@ diff --git a/lib/commonjs/components/KeyboardChatScrollView/index.js b/lib/commonjs/components/KeyboardChatScrollView/index.js -index db8cfb1d289f91563f13c4dd842c783c99facc32..940e1dd80c6c9dfc42eab916445be414372ce52e 100644 +index db8cfb1d289f91563f13c4dd842c783c99facc32..64c7e38ad8d9e866b15a53e561509e6b0ad33cb9 100644 --- a/lib/commonjs/components/KeyboardChatScrollView/index.js +++ b/lib/commonjs/components/KeyboardChatScrollView/index.js -@@ -26,9 +26,11 @@ const KeyboardChatScrollView = /*#__PURE__*/(0, _react.forwardRef)(({ +@@ -26,9 +26,12 @@ const KeyboardChatScrollView = /*#__PURE__*/(0, _react.forwardRef)(({ offset = 0, extraContentPadding = ZERO_CONTENT_PADDING, blankSpace = ZERO_BLANK_SPACE, + adjustedInsetCompensation = 0, ++ adjustedStartInsetCompensation = 0, applyWorkaroundForContentInsetHitTestBug = false, onLayout: onLayoutProp, onContentSizeChange: onContentSizeChangeProp, @@ -14,13 +15,14 @@ index db8cfb1d289f91563f13c4dd842c783c99facc32..940e1dd80c6c9dfc42eab916445be414 onEndVisible, ...rest }, ref) => { -@@ -50,13 +52,15 @@ const KeyboardChatScrollView = /*#__PURE__*/(0, _react.forwardRef)(({ +@@ -50,13 +53,17 @@ const KeyboardChatScrollView = /*#__PURE__*/(0, _react.forwardRef)(({ freeze: freezeSV, offset, blankSpace, - extraContentPadding + extraContentPadding, -+ adjustedInsetCompensation ++ adjustedInsetCompensation, ++ adjustedStartInsetCompensation }); (0, _useExtraContentPadding.useExtraContentPadding)({ scrollViewRef, @@ -28,17 +30,23 @@ index db8cfb1d289f91563f13c4dd842c783c99facc32..940e1dd80c6c9dfc42eab916445be414 keyboardPadding: padding, blankSpace, + adjustedInsetCompensation, ++ adjustedStartInsetCompensation, scroll, layout, size, -@@ -82,10 +86,21 @@ const KeyboardChatScrollView = /*#__PURE__*/(0, _react.forwardRef)(({ +@@ -82,10 +89,26 @@ const KeyboardChatScrollView = /*#__PURE__*/(0, _react.forwardRef)(({ // a bug for you, please open an issue. const totalPadding = (0, _reactNativeReanimated.useDerivedValue)(() => Math.min(layout.value.height, Math.max(blankSpace.value, padding.value + extraContentPadding.value))); ++ // iOS applies the destination contentInset and contentOffset together at ++ // keyboard-animation start. Keep that native target behavior, but report ++ // the presentation height so virtualized-list layout follows the keyboard. ++ const reportedPadding = (0, _reactNativeReanimated.useDerivedValue)(() => _reactNative.Platform.OS === "ios" ? Math.min(layout.value.height, Math.max(blankSpace.value, currentHeight.value + extraContentPadding.value)) : totalPadding.value); ++ + // Mirror the effective bottom padding (keyboard + composer + blank floor) + // to the consumer - a virtualized list needs it in its own scroll math or + // its end/maintain targets point at the under-the-keyboard resting offset. -+ (0, _reactNativeReanimated.useAnimatedReaction)(() => totalPadding.value, (current, previous) => { ++ (0, _reactNativeReanimated.useAnimatedReaction)(() => reportedPadding.value, (current, previous) => { + if (onContentInsetChange && current !== previous) { + (0, _reactNativeReanimated.runOnJS)(onContentInsetChange)({ + bottom: current @@ -54,21 +62,57 @@ index db8cfb1d289f91563f13c4dd842c783c99facc32..940e1dd80c6c9dfc42eab916445be414 const onLayout = (0, _react.useCallback)(e => { onLayoutInternal(e); onLayoutProp === null || onLayoutProp === void 0 || onLayoutProp(e); +diff --git a/lib/commonjs/components/KeyboardChatScrollView/useChatKeyboard/helpers.js b/lib/commonjs/components/KeyboardChatScrollView/useChatKeyboard/helpers.js +index 4d33b3fe426070cf312fc493ce902b2c24d853ea..e619708b95865afd8b43dd8f0a6523fcf67b1f53 100644 +--- a/lib/commonjs/components/KeyboardChatScrollView/useChatKeyboard/helpers.js ++++ b/lib/commonjs/components/KeyboardChatScrollView/useChatKeyboard/helpers.js +@@ -206,7 +206,7 @@ const clampedScrollTarget = (offsetBeforeScroll, keyboardHeight, contentHeight, + * ``` + */ + exports.clampedScrollTarget = clampedScrollTarget; +-const computeIOSContentOffset = (relativeScroll, keyboardHeight, contentHeight, layoutHeight, inverted, totalPaddingForMaxScroll) => { ++const computeIOSContentOffset = (relativeScroll, keyboardHeight, contentHeight, layoutHeight, inverted, totalPaddingForMaxScroll, startInsetCompensation = 0) => { + "worklet"; + + const paddingForMax = totalPaddingForMaxScroll !== undefined ? totalPaddingForMaxScroll : keyboardHeight; +@@ -214,8 +214,9 @@ const computeIOSContentOffset = (relativeScroll, keyboardHeight, contentHeight, + const maxScroll = Math.max(contentHeight - layoutHeight, 0); + return Math.max(Math.min(relativeScroll - keyboardHeight, maxScroll), -paddingForMax); + } +- const maxScroll = Math.max(contentHeight - layoutHeight + paddingForMax, 0); +- return Math.min(Math.max(keyboardHeight + relativeScroll, 0), maxScroll); ++ const minScroll = -startInsetCompensation; ++ const maxScroll = Math.max(contentHeight - layoutHeight + paddingForMax, minScroll); ++ return Math.min(Math.max(keyboardHeight + relativeScroll, minScroll), maxScroll); + }; + exports.computeIOSContentOffset = computeIOSContentOffset; + //# sourceMappingURL=helpers.js.map diff --git a/lib/commonjs/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js b/lib/commonjs/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js -index 2073da84b8b2be291aa3181700c2b18a75d0fc56..f43f2efdc4f0eda0460720839544dc6e7f4a54e0 100644 +index 2073da84b8b2be291aa3181700c2b18a75d0fc56..7ae9bb220ce3cc23c788a79677afdc7d9547a27c 100644 --- a/lib/commonjs/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js +++ b/lib/commonjs/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js -@@ -32,7 +32,8 @@ function useChatKeyboard(scrollViewRef, options) { +@@ -32,7 +32,9 @@ function useChatKeyboard(scrollViewRef, options) { freeze, offset, blankSpace, - extraContentPadding + extraContentPadding, -+ adjustedInsetCompensation ++ adjustedInsetCompensation, ++ adjustedStartInsetCompensation } = options; const padding = (0, _reactNativeReanimated.useSharedValue)(0); const currentHeight = (0, _reactNativeReanimated.useSharedValue)(0); -@@ -66,7 +67,7 @@ function useChatKeyboard(scrollViewRef, options) { +@@ -58,6 +60,9 @@ function useChatKeyboard(scrollViewRef, options) { + targetKeyboardHeight.value = e.height; + } + const effective = (0, _helpers.getEffectiveHeight)(e.height, targetKeyboardHeight.value, offset); ++ if (e.duration === 0) { ++ currentHeight.value = effective; ++ } + const atEnd = (0, _helpers.isScrollAtEnd)(scroll.value, layout.value.height, size.value.height, inverted); + + // Scale minimum padding absorption by how much of it is visible. +@@ -66,7 +71,7 @@ function useChatKeyboard(scrollViewRef, options) { const visiblePadding = visibleFraction * blankSpace.value; const minimumPaddingAbsorbed = Math.max(0, visiblePadding - extraContentPadding.value); const scrollEffective = (0, _helpers.getScrollEffective)(effective, minimumPaddingAbsorbed); @@ -77,28 +121,81 @@ index 2073da84b8b2be291aa3181700c2b18a75d0fc56..f43f2efdc4f0eda0460720839544dc6e // persistent mode: when keyboard shrinks, clamp to valid range if (keyboardLiftBehavior === "persistent" && effective < padding.value) { -@@ -134,7 +135,7 @@ function useChatKeyboard(scrollViewRef, options) { +@@ -76,8 +81,8 @@ function useChatKeyboard(scrollViewRef, options) { + const maxScroll = Math.max(size.value.height - layout.value.height, 0); + contentOffsetY.value = Math.max(-actualTotalPadding, Math.min(scroll.value, maxScroll)); + } else { +- const maxScroll = Math.max(size.value.height - layout.value.height + actualTotalPadding, 0); +- contentOffsetY.value = Math.max(0, Math.min(scroll.value, maxScroll)); ++ const maxScroll = Math.max(size.value.height - layout.value.height + actualTotalPadding, -adjustedStartInsetCompensation); ++ contentOffsetY.value = Math.max(-adjustedStartInsetCompensation, Math.min(scroll.value, maxScroll)); + } + return; + } +@@ -91,8 +96,8 @@ function useChatKeyboard(scrollViewRef, options) { + const maxScroll = Math.max(size.value.height - layout.value.height, 0); + contentOffsetY.value = Math.max(-actualTotalPadding, Math.min(scroll.value, maxScroll)); + } else { +- const maxScroll = Math.max(size.value.height - layout.value.height + actualTotalPadding, 0); +- contentOffsetY.value = Math.max(0, Math.min(scroll.value, maxScroll)); ++ const maxScroll = Math.max(size.value.height - layout.value.height + actualTotalPadding, -adjustedStartInsetCompensation); ++ contentOffsetY.value = Math.max(-adjustedStartInsetCompensation, Math.min(scroll.value, maxScroll)); + } + return; + } +@@ -118,12 +123,23 @@ function useChatKeyboard(scrollViewRef, options) { + contentOffsetY.value = scroll.value; + return; + } +- contentOffsetY.value = (0, _helpers.computeIOSContentOffset)(relativeScroll, scrollEffective, size.value.height, layout.value.height, inverted, actualTotalPadding); ++ contentOffsetY.value = (0, _helpers.computeIOSContentOffset)(relativeScroll, scrollEffective, size.value.height, layout.value.height, inverted, actualTotalPadding, adjustedStartInsetCompensation); ++ }, ++ onMove: e => { ++ "worklet"; ++ ++ if (freeze.value) { ++ return; ++ } ++ currentHeight.value = (0, _helpers.getEffectiveHeight)(e.height, targetKeyboardHeight.value, offset); + }, +- onMove: () => { ++ onInteractive: e => { + "worklet"; + +- // iOS doesn't need per-frame updates (contentOffset handles it) ++ if (freeze.value) { ++ return; ++ } ++ currentHeight.value = (0, _helpers.getEffectiveHeight)(e.height, targetKeyboardHeight.value, offset); + }, + onEnd: e => { + "worklet"; +@@ -132,9 +148,10 @@ function useChatKeyboard(scrollViewRef, options) { + return; + } const effective = (0, _helpers.getEffectiveHeight)(e.height, targetKeyboardHeight.value, offset); ++ currentHeight.value = effective; padding.value = effective; } - }, [inverted, keyboardLiftBehavior, offset, extraContentPadding]); -+ }, [inverted, keyboardLiftBehavior, offset, extraContentPadding, adjustedInsetCompensation]); ++ }, [inverted, keyboardLiftBehavior, offset, extraContentPadding, adjustedInsetCompensation, adjustedStartInsetCompensation]); return { padding, currentHeight, diff --git a/lib/commonjs/components/KeyboardChatScrollView/useExtraContentPadding/index.js b/lib/commonjs/components/KeyboardChatScrollView/useExtraContentPadding/index.js -index 0d50bdfeb7bbc5c14a31ed344f8a690e4fd340b3..22ca193257ab0068f732c088b09145dabf39a05e 100644 +index 0d50bdfeb7bbc5c14a31ed344f8a690e4fd340b3..e0b73653fd1dd23f0f2e2c85835a3d107e3f666d 100644 --- a/lib/commonjs/components/KeyboardChatScrollView/useExtraContentPadding/index.js +++ b/lib/commonjs/components/KeyboardChatScrollView/useExtraContentPadding/index.js -@@ -29,6 +29,7 @@ function useExtraContentPadding(options) { +@@ -29,6 +29,8 @@ function useExtraContentPadding(options) { extraContentPadding, keyboardPadding, blankSpace, + adjustedInsetCompensation, ++ adjustedStartInsetCompensation, scroll, layout, size, -@@ -68,8 +69,8 @@ function useExtraContentPadding(options) { +@@ -68,8 +70,8 @@ function useExtraContentPadding(options) { } // Compute effective delta considering blankSpace floor @@ -109,33 +206,41 @@ index 0d50bdfeb7bbc5c14a31ed344f8a690e4fd340b3..22ca193257ab0068f732c088b09145da const effectiveDelta = currentTotal - previousTotal; if (effectiveDelta === 0) { // blankSpace absorbed the change -@@ -92,6 +93,6 @@ function useExtraContentPadding(options) { - const target = Math.min(scroll.value + effectiveDelta, maxScroll); +@@ -88,10 +90,11 @@ function useExtraContentPadding(options) { + const target = Math.max(scroll.value - effectiveDelta, -currentTotal); + scrollToTarget(target); + } else { +- const maxScroll = Math.max(size.value.height - layout.value.height + currentTotal, 0); +- const target = Math.min(scroll.value + effectiveDelta, maxScroll); ++ const minScroll = -adjustedStartInsetCompensation; ++ const maxScroll = Math.max(size.value.height - layout.value.height + currentTotal, minScroll); ++ const target = Math.max(minScroll, Math.min(scroll.value + effectiveDelta, maxScroll)); scrollToTarget(target); } - }, [inverted, keyboardLiftBehavior]); -+ }, [inverted, keyboardLiftBehavior, adjustedInsetCompensation]); ++ }, [inverted, keyboardLiftBehavior, adjustedInsetCompensation, adjustedStartInsetCompensation]); } //# sourceMappingURL=index.js.map -\ No newline at end of file diff --git a/lib/module/components/KeyboardChatScrollView/index.js b/lib/module/components/KeyboardChatScrollView/index.js -index 612dd8bd9bd6cc3e30a5acac937ea3383eb1b630..ac79433fdf0b36f89525b9840447a116da63d58c 100644 +index 612dd8bd9bd6cc3e30a5acac937ea3383eb1b630..76b858a6590ad0592cc663864ea822db55eaeceb 100644 --- a/lib/module/components/KeyboardChatScrollView/index.js +++ b/lib/module/components/KeyboardChatScrollView/index.js @@ -1,7 +1,7 @@ function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } return n; }, _extends.apply(null, arguments); } import React, { forwardRef, useCallback, useMemo } from "react"; - import { StyleSheet } from "react-native"; +-import { StyleSheet } from "react-native"; -import { makeMutable, useAnimatedRef, useAnimatedStyle, useDerivedValue } from "react-native-reanimated"; ++import { Platform, StyleSheet } from "react-native"; +import { makeMutable, runOnJS, useAnimatedReaction, useAnimatedRef, useAnimatedStyle, useDerivedValue } from "react-native-reanimated"; import Reanimated from "react-native-reanimated"; import useCombinedRef from "../hooks/useCombinedRef"; import ScrollViewWithBottomPadding from "../ScrollViewWithBottomPadding"; -@@ -19,9 +19,11 @@ const KeyboardChatScrollView = /*#__PURE__*/forwardRef(({ +@@ -19,9 +19,12 @@ const KeyboardChatScrollView = /*#__PURE__*/forwardRef(({ offset = 0, extraContentPadding = ZERO_CONTENT_PADDING, blankSpace = ZERO_BLANK_SPACE, + adjustedInsetCompensation = 0, ++ adjustedStartInsetCompensation = 0, applyWorkaroundForContentInsetHitTestBug = false, onLayout: onLayoutProp, onContentSizeChange: onContentSizeChangeProp, @@ -143,13 +248,14 @@ index 612dd8bd9bd6cc3e30a5acac937ea3383eb1b630..ac79433fdf0b36f89525b9840447a116 onEndVisible, ...rest }, ref) => { -@@ -43,13 +45,15 @@ const KeyboardChatScrollView = /*#__PURE__*/forwardRef(({ +@@ -43,13 +46,17 @@ const KeyboardChatScrollView = /*#__PURE__*/forwardRef(({ freeze: freezeSV, offset, blankSpace, - extraContentPadding + extraContentPadding, -+ adjustedInsetCompensation ++ adjustedInsetCompensation, ++ adjustedStartInsetCompensation }); useExtraContentPadding({ scrollViewRef, @@ -157,17 +263,23 @@ index 612dd8bd9bd6cc3e30a5acac937ea3383eb1b630..ac79433fdf0b36f89525b9840447a116 keyboardPadding: padding, blankSpace, + adjustedInsetCompensation, ++ adjustedStartInsetCompensation, scroll, layout, size, -@@ -75,10 +79,21 @@ const KeyboardChatScrollView = /*#__PURE__*/forwardRef(({ +@@ -75,10 +82,26 @@ const KeyboardChatScrollView = /*#__PURE__*/forwardRef(({ // a bug for you, please open an issue. const totalPadding = useDerivedValue(() => Math.min(layout.value.height, Math.max(blankSpace.value, padding.value + extraContentPadding.value))); ++ // iOS applies the destination contentInset and contentOffset together at ++ // keyboard-animation start. Keep that native target behavior, but report ++ // the presentation height so virtualized-list layout follows the keyboard. ++ const reportedPadding = useDerivedValue(() => Platform.OS === "ios" ? Math.min(layout.value.height, Math.max(blankSpace.value, currentHeight.value + extraContentPadding.value)) : totalPadding.value); ++ + // Mirror the effective bottom padding (keyboard + composer + blank floor) + // to the consumer - a virtualized list needs it in its own scroll math or + // its end/maintain targets point at the under-the-keyboard resting offset. -+ useAnimatedReaction(() => totalPadding.value, (current, previous) => { ++ useAnimatedReaction(() => reportedPadding.value, (current, previous) => { + if (onContentInsetChange && current !== previous) { + runOnJS(onContentInsetChange)({ + bottom: current @@ -183,21 +295,56 @@ index 612dd8bd9bd6cc3e30a5acac937ea3383eb1b630..ac79433fdf0b36f89525b9840447a116 const onLayout = useCallback(e => { onLayoutInternal(e); onLayoutProp === null || onLayoutProp === void 0 || onLayoutProp(e); +diff --git a/lib/module/components/KeyboardChatScrollView/useChatKeyboard/helpers.js b/lib/module/components/KeyboardChatScrollView/useChatKeyboard/helpers.js +index 295e2219b560519b8d033968b5105d1da3e77315..ff7ee3fe0d880c2287a7b70d5ecf952e99357adf 100644 +--- a/lib/module/components/KeyboardChatScrollView/useChatKeyboard/helpers.js ++++ b/lib/module/components/KeyboardChatScrollView/useChatKeyboard/helpers.js +@@ -193,7 +193,7 @@ export const clampedScrollTarget = (offsetBeforeScroll, keyboardHeight, contentH + * computeIOSContentOffset(100, 300, 1000, 800, false); // 400 + * ``` + */ +-export const computeIOSContentOffset = (relativeScroll, keyboardHeight, contentHeight, layoutHeight, inverted, totalPaddingForMaxScroll) => { ++export const computeIOSContentOffset = (relativeScroll, keyboardHeight, contentHeight, layoutHeight, inverted, totalPaddingForMaxScroll, startInsetCompensation = 0) => { + "worklet"; + + const paddingForMax = totalPaddingForMaxScroll !== undefined ? totalPaddingForMaxScroll : keyboardHeight; +@@ -201,7 +201,8 @@ export const computeIOSContentOffset = (relativeScroll, keyboardHeight, contentH + const maxScroll = Math.max(contentHeight - layoutHeight, 0); + return Math.max(Math.min(relativeScroll - keyboardHeight, maxScroll), -paddingForMax); + } +- const maxScroll = Math.max(contentHeight - layoutHeight + paddingForMax, 0); +- return Math.min(Math.max(keyboardHeight + relativeScroll, 0), maxScroll); ++ const minScroll = -startInsetCompensation; ++ const maxScroll = Math.max(contentHeight - layoutHeight + paddingForMax, minScroll); ++ return Math.min(Math.max(keyboardHeight + relativeScroll, minScroll), maxScroll); + }; + //# sourceMappingURL=helpers.js.map diff --git a/lib/module/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js b/lib/module/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js -index 52943c3a7d6a68fe2094dc1d112c07e6b9d890e4..c8685c18a53ad078c24fc3e3b6572669b2b63397 100644 +index 52943c3a7d6a68fe2094dc1d112c07e6b9d890e4..cc4bfae859191aeb848179028b7217098cba2340 100644 --- a/lib/module/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js +++ b/lib/module/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js -@@ -25,7 +25,8 @@ function useChatKeyboard(scrollViewRef, options) { +@@ -25,7 +25,9 @@ function useChatKeyboard(scrollViewRef, options) { freeze, offset, blankSpace, - extraContentPadding + extraContentPadding, -+ adjustedInsetCompensation ++ adjustedInsetCompensation, ++ adjustedStartInsetCompensation } = options; const padding = useSharedValue(0); const currentHeight = useSharedValue(0); -@@ -59,7 +60,7 @@ function useChatKeyboard(scrollViewRef, options) { +@@ -51,6 +53,9 @@ function useChatKeyboard(scrollViewRef, options) { + targetKeyboardHeight.value = e.height; + } + const effective = getEffectiveHeight(e.height, targetKeyboardHeight.value, offset); ++ if (e.duration === 0) { ++ currentHeight.value = effective; ++ } + const atEnd = isScrollAtEnd(scroll.value, layout.value.height, size.value.height, inverted); + + // Scale minimum padding absorption by how much of it is visible. +@@ -59,7 +64,7 @@ function useChatKeyboard(scrollViewRef, options) { const visiblePadding = visibleFraction * blankSpace.value; const minimumPaddingAbsorbed = Math.max(0, visiblePadding - extraContentPadding.value); const scrollEffective = getScrollEffective(effective, minimumPaddingAbsorbed); @@ -206,28 +353,81 @@ index 52943c3a7d6a68fe2094dc1d112c07e6b9d890e4..c8685c18a53ad078c24fc3e3b6572669 // persistent mode: when keyboard shrinks, clamp to valid range if (keyboardLiftBehavior === "persistent" && effective < padding.value) { -@@ -127,7 +128,7 @@ function useChatKeyboard(scrollViewRef, options) { +@@ -69,8 +74,8 @@ function useChatKeyboard(scrollViewRef, options) { + const maxScroll = Math.max(size.value.height - layout.value.height, 0); + contentOffsetY.value = Math.max(-actualTotalPadding, Math.min(scroll.value, maxScroll)); + } else { +- const maxScroll = Math.max(size.value.height - layout.value.height + actualTotalPadding, 0); +- contentOffsetY.value = Math.max(0, Math.min(scroll.value, maxScroll)); ++ const maxScroll = Math.max(size.value.height - layout.value.height + actualTotalPadding, -adjustedStartInsetCompensation); ++ contentOffsetY.value = Math.max(-adjustedStartInsetCompensation, Math.min(scroll.value, maxScroll)); + } + return; + } +@@ -84,8 +89,8 @@ function useChatKeyboard(scrollViewRef, options) { + const maxScroll = Math.max(size.value.height - layout.value.height, 0); + contentOffsetY.value = Math.max(-actualTotalPadding, Math.min(scroll.value, maxScroll)); + } else { +- const maxScroll = Math.max(size.value.height - layout.value.height + actualTotalPadding, 0); +- contentOffsetY.value = Math.max(0, Math.min(scroll.value, maxScroll)); ++ const maxScroll = Math.max(size.value.height - layout.value.height + actualTotalPadding, -adjustedStartInsetCompensation); ++ contentOffsetY.value = Math.max(-adjustedStartInsetCompensation, Math.min(scroll.value, maxScroll)); + } + return; + } +@@ -111,12 +116,23 @@ function useChatKeyboard(scrollViewRef, options) { + contentOffsetY.value = scroll.value; + return; + } +- contentOffsetY.value = computeIOSContentOffset(relativeScroll, scrollEffective, size.value.height, layout.value.height, inverted, actualTotalPadding); ++ contentOffsetY.value = computeIOSContentOffset(relativeScroll, scrollEffective, size.value.height, layout.value.height, inverted, actualTotalPadding, adjustedStartInsetCompensation); ++ }, ++ onMove: e => { ++ "worklet"; ++ ++ if (freeze.value) { ++ return; ++ } ++ currentHeight.value = getEffectiveHeight(e.height, targetKeyboardHeight.value, offset); + }, +- onMove: () => { ++ onInteractive: e => { + "worklet"; + +- // iOS doesn't need per-frame updates (contentOffset handles it) ++ if (freeze.value) { ++ return; ++ } ++ currentHeight.value = getEffectiveHeight(e.height, targetKeyboardHeight.value, offset); + }, + onEnd: e => { + "worklet"; +@@ -125,9 +141,10 @@ function useChatKeyboard(scrollViewRef, options) { + return; + } const effective = getEffectiveHeight(e.height, targetKeyboardHeight.value, offset); ++ currentHeight.value = effective; padding.value = effective; } - }, [inverted, keyboardLiftBehavior, offset, extraContentPadding]); -+ }, [inverted, keyboardLiftBehavior, offset, extraContentPadding, adjustedInsetCompensation]); ++ }, [inverted, keyboardLiftBehavior, offset, extraContentPadding, adjustedInsetCompensation, adjustedStartInsetCompensation]); return { padding, currentHeight, diff --git a/lib/module/components/KeyboardChatScrollView/useExtraContentPadding/index.js b/lib/module/components/KeyboardChatScrollView/useExtraContentPadding/index.js -index 1afa50987a8d2a5fe3f36b20945efe804d48a873..e2966d89d4329a7dc1233ff060cab6f365f745d6 100644 +index 1afa50987a8d2a5fe3f36b20945efe804d48a873..7b05d0721c50e118cf78c07913b580a31293db77 100644 --- a/lib/module/components/KeyboardChatScrollView/useExtraContentPadding/index.js +++ b/lib/module/components/KeyboardChatScrollView/useExtraContentPadding/index.js -@@ -23,6 +23,7 @@ function useExtraContentPadding(options) { +@@ -23,6 +23,8 @@ function useExtraContentPadding(options) { extraContentPadding, keyboardPadding, blankSpace, + adjustedInsetCompensation, ++ adjustedStartInsetCompensation, scroll, layout, size, -@@ -62,8 +63,8 @@ function useExtraContentPadding(options) { +@@ -62,8 +64,8 @@ function useExtraContentPadding(options) { } // Compute effective delta considering blankSpace floor @@ -238,61 +438,85 @@ index 1afa50987a8d2a5fe3f36b20945efe804d48a873..e2966d89d4329a7dc1233ff060cab6f3 const effectiveDelta = currentTotal - previousTotal; if (effectiveDelta === 0) { // blankSpace absorbed the change -@@ -86,7 +87,7 @@ function useExtraContentPadding(options) { - const target = Math.min(scroll.value + effectiveDelta, maxScroll); +@@ -82,11 +84,12 @@ function useExtraContentPadding(options) { + const target = Math.max(scroll.value - effectiveDelta, -currentTotal); + scrollToTarget(target); + } else { +- const maxScroll = Math.max(size.value.height - layout.value.height + currentTotal, 0); +- const target = Math.min(scroll.value + effectiveDelta, maxScroll); ++ const minScroll = -adjustedStartInsetCompensation; ++ const maxScroll = Math.max(size.value.height - layout.value.height + currentTotal, minScroll); ++ const target = Math.max(minScroll, Math.min(scroll.value + effectiveDelta, maxScroll)); scrollToTarget(target); } - }, [inverted, keyboardLiftBehavior]); -+ }, [inverted, keyboardLiftBehavior, adjustedInsetCompensation]); ++ }, [inverted, keyboardLiftBehavior, adjustedInsetCompensation, adjustedStartInsetCompensation]); } export { useExtraContentPadding }; //# sourceMappingURL=index.js.map -\ No newline at end of file diff --git a/lib/typescript/components/KeyboardChatScrollView/types.d.ts b/lib/typescript/components/KeyboardChatScrollView/types.d.ts -index a036b431f03efb9d5379527c17db6fce62bfee09..6ca6fdf60fc9fae1e28a86cf24e21beed99b3a76 100644 +index a036b431f03efb9d5379527c17db6fce62bfee09..5e56bf7edec9161698a745e8e6292bfccd68d3db 100644 --- a/lib/typescript/components/KeyboardChatScrollView/types.d.ts +++ b/lib/typescript/components/KeyboardChatScrollView/types.d.ts -@@ -86,6 +86,8 @@ export type KeyboardChatScrollViewProps = { +@@ -86,6 +86,10 @@ export type KeyboardChatScrollViewProps = { * Default is `undefined` (equivalent to `0` — no minimum floor). */ blankSpace?: SharedValue; + /** Extra bottom inset UIKit adds beyond the raw contentInset (safe area). Offset math only. */ + adjustedInsetCompensation?: number; ++ /** Leading inset UIKit adds outside raw contentInset. Offset math only. */ ++ adjustedStartInsetCompensation?: number; /** * Fires whenever the effective content inset changes — the static `contentInset` * prop combined with the dynamic keyboard-driven padding. +diff --git a/lib/typescript/components/KeyboardChatScrollView/useChatKeyboard/helpers.d.ts b/lib/typescript/components/KeyboardChatScrollView/useChatKeyboard/helpers.d.ts +index 2b51cc4737b4a05b5396da31e67e54177f43c498..3acec1fbddee6227915590a6c8d449757cb4d7b2 100644 +--- a/lib/typescript/components/KeyboardChatScrollView/useChatKeyboard/helpers.d.ts ++++ b/lib/typescript/components/KeyboardChatScrollView/useChatKeyboard/helpers.d.ts +@@ -129,4 +129,4 @@ export declare const clampedScrollTarget: (offsetBeforeScroll: number, keyboardH + * computeIOSContentOffset(100, 300, 1000, 800, false); // 400 + * ``` + */ +-export declare const computeIOSContentOffset: (relativeScroll: number, keyboardHeight: number, contentHeight: number, layoutHeight: number, inverted: boolean, totalPaddingForMaxScroll?: number) => number; ++export declare const computeIOSContentOffset: (relativeScroll: number, keyboardHeight: number, contentHeight: number, layoutHeight: number, inverted: boolean, totalPaddingForMaxScroll?: number, startInsetCompensation?: number) => number; diff --git a/lib/typescript/components/KeyboardChatScrollView/useChatKeyboard/types.d.ts b/lib/typescript/components/KeyboardChatScrollView/useChatKeyboard/types.d.ts -index aff9b5a8dbc2464546396437eaf6c5ae955b9f29..67edbe1a1eac27b5a571611979753ebf3134bc8b 100644 +index aff9b5a8dbc2464546396437eaf6c5ae955b9f29..fdd83953271123be6caefaa786c469f0d92cce2b 100644 --- a/lib/typescript/components/KeyboardChatScrollView/useChatKeyboard/types.d.ts +++ b/lib/typescript/components/KeyboardChatScrollView/useChatKeyboard/types.d.ts -@@ -9,6 +9,8 @@ type UseChatKeyboardOptions = { +@@ -9,6 +9,10 @@ type UseChatKeyboardOptions = { blankSpace: SharedValue; /** Extra content padding shared value — needed on iOS to correctly clamp contentOffset. */ extraContentPadding: SharedValue; + /** Safe-area extra beyond raw contentInset. Offset math only. */ + adjustedInsetCompensation: number; ++ /** Leading inset UIKit adds beyond the raw contentInset. Offset math only. */ ++ adjustedStartInsetCompensation: number; }; type UseChatKeyboardReturn = { /** Extra scrollable space (= keyboard height). Used as contentInset on iOS, contentInsetBottom/contentInsetTop on Android. */ diff --git a/lib/typescript/components/KeyboardChatScrollView/useExtraContentPadding/index.d.ts b/lib/typescript/components/KeyboardChatScrollView/useExtraContentPadding/index.d.ts -index ec73f70544a062fbecfc1d8be92d839d3e0bea6f..6fe16cd0b1200a2f4d4d8eeb9b555747186dbfe0 100644 +index ec73f70544a062fbecfc1d8be92d839d3e0bea6f..45b387446587fd3e611fa02a01534bb5d9fa6bbd 100644 --- a/lib/typescript/components/KeyboardChatScrollView/useExtraContentPadding/index.d.ts +++ b/lib/typescript/components/KeyboardChatScrollView/useExtraContentPadding/index.d.ts -@@ -8,6 +8,8 @@ type UseExtraContentPaddingOptions = { +@@ -8,6 +8,10 @@ type UseExtraContentPaddingOptions = { keyboardPadding: SharedValue; /** Minimum inset floor — used to absorb keyboard and extraContentPadding changes. */ blankSpace: SharedValue; + /** Safe-area extra beyond raw contentInset. Offset math only. */ + adjustedInsetCompensation: number; ++ /** Leading inset UIKit adds beyond raw contentInset. Offset math only. */ ++ adjustedStartInsetCompensation: number; /** Current vertical scroll offset. */ scroll: SharedValue; /** Visible viewport dimensions. */ diff --git a/src/components/KeyboardChatScrollView/index.tsx b/src/components/KeyboardChatScrollView/index.tsx -index 03f5f74e9aaaabc75db1c01643a655ee4fdfa5f2..d657002ebbce53c47e3a713c0921c35dca6056f3 100644 +index 03f5f74e9aaaabc75db1c01643a655ee4fdfa5f2..277a1ecada428ffbae517d6edc720d029c8ecc10 100644 --- a/src/components/KeyboardChatScrollView/index.tsx +++ b/src/components/KeyboardChatScrollView/index.tsx -@@ -2,6 +2,8 @@ import React, { forwardRef, useCallback, useMemo } from "react"; - import { StyleSheet } from "react-native"; +@@ -1,7 +1,9 @@ + import React, { forwardRef, useCallback, useMemo } from "react"; +-import { StyleSheet } from "react-native"; ++import { Platform, StyleSheet } from "react-native"; import { makeMutable, + runOnJS, @@ -300,11 +524,12 @@ index 03f5f74e9aaaabc75db1c01643a655ee4fdfa5f2..d657002ebbce53c47e3a713c0921c35d useAnimatedRef, useAnimatedStyle, useDerivedValue, -@@ -35,9 +37,11 @@ const KeyboardChatScrollView = forwardRef< +@@ -35,9 +37,12 @@ const KeyboardChatScrollView = forwardRef< offset = 0, extraContentPadding = ZERO_CONTENT_PADDING, blankSpace = ZERO_BLANK_SPACE, + adjustedInsetCompensation = 0, ++ adjustedStartInsetCompensation = 0, applyWorkaroundForContentInsetHitTestBug = false, onLayout: onLayoutProp, onContentSizeChange: onContentSizeChangeProp, @@ -312,23 +537,25 @@ index 03f5f74e9aaaabc75db1c01643a655ee4fdfa5f2..d657002ebbce53c47e3a713c0921c35d onEndVisible, ...rest }, -@@ -64,6 +68,7 @@ const KeyboardChatScrollView = forwardRef< +@@ -64,6 +69,8 @@ const KeyboardChatScrollView = forwardRef< offset, blankSpace, extraContentPadding, + adjustedInsetCompensation, ++ adjustedStartInsetCompensation, }); useExtraContentPadding({ -@@ -71,6 +76,7 @@ const KeyboardChatScrollView = forwardRef< +@@ -71,6 +78,8 @@ const KeyboardChatScrollView = forwardRef< extraContentPadding, keyboardPadding: padding, blankSpace, + adjustedInsetCompensation, ++ adjustedStartInsetCompensation, scroll, layout, size, -@@ -102,13 +108,25 @@ const KeyboardChatScrollView = forwardRef< +@@ -102,13 +111,40 @@ const KeyboardChatScrollView = forwardRef< ), ); @@ -337,19 +564,34 @@ index 03f5f74e9aaaabc75db1c01643a655ee4fdfa5f2..d657002ebbce53c47e3a713c0921c35d - // scrollIndicatorInsets adjustment at the application layer. - const indicatorPadding = useDerivedValue( - () => padding.value + extraContentPadding.value, ++ // iOS applies the destination contentInset and contentOffset together at ++ // keyboard-animation start. Keep that native target behavior, but report ++ // the presentation height so virtualized-list layout follows the keyboard. ++ const reportedPadding = useDerivedValue(() => ++ Platform.OS === "ios" ++ ? Math.min( ++ layout.value.height, ++ Math.max( ++ blankSpace.value, ++ currentHeight.value + extraContentPadding.value, ++ ), ++ ) ++ : totalPadding.value, + ); + + // Mirror the effective bottom padding (keyboard + composer + blank floor) + // to the consumer — a virtualized list needs it in its own scroll math or + // its end/maintain targets point at the under-the-keyboard resting offset. + useAnimatedReaction( -+ () => totalPadding.value, ++ () => reportedPadding.value, + (current, previous) => { + if (onContentInsetChange && current !== previous) { + runOnJS(onContentInsetChange)({ bottom: current }); + } + }, + [onContentInsetChange], - ); - ++ ); ++ + // Scroll indicator inset = keyboard only (excludes extraContentPadding and + // blankSpace): with a floating composer the indicator track should run the + // full height of the scroll view, behind the composer, like iOS Messages. @@ -360,10 +602,10 @@ index 03f5f74e9aaaabc75db1c01643a655ee4fdfa5f2..d657002ebbce53c47e3a713c0921c35d (e: LayoutChangeEvent) => { onLayoutInternal(e); diff --git a/src/components/KeyboardChatScrollView/types.ts b/src/components/KeyboardChatScrollView/types.ts -index dd222b57bc7a71729524670bad3812f30920bd73..40249a4357991b7a9c69b87214db2ceb6ff4ba7d 100644 +index dd222b57bc7a71729524670bad3812f30920bd73..7be71b5460eb143ddf460ce5c87b843c66bfb2cd 100644 --- a/src/components/KeyboardChatScrollView/types.ts +++ b/src/components/KeyboardChatScrollView/types.ts -@@ -90,6 +90,15 @@ export type KeyboardChatScrollViewProps = { +@@ -90,6 +90,17 @@ export type KeyboardChatScrollViewProps = { * Default is `undefined` (equivalent to `0` — no minimum floor). */ blankSpace?: SharedValue; @@ -376,22 +618,65 @@ index dd222b57bc7a71729524670bad3812f30920bd73..40249a4357991b7a9c69b87214db2ceb + * Default is `0`. + */ + adjustedInsetCompensation?: number; ++ /** Leading inset UIKit adds outside raw contentInset. Offset math only. */ ++ adjustedStartInsetCompensation?: number; /** * Fires whenever the effective content inset changes — the static `contentInset` * prop combined with the dynamic keyboard-driven padding. +diff --git a/src/components/KeyboardChatScrollView/useChatKeyboard/helpers.ts b/src/components/KeyboardChatScrollView/useChatKeyboard/helpers.ts +index 37eacd10857cd70c51b3468c4b466e4959d0a415..3d77ab27c614f44b64da8270823942cb670b097b 100644 +--- a/src/components/KeyboardChatScrollView/useChatKeyboard/helpers.ts ++++ b/src/components/KeyboardChatScrollView/useChatKeyboard/helpers.ts +@@ -245,6 +245,7 @@ export const computeIOSContentOffset = ( + layoutHeight: number, + inverted: boolean, + totalPaddingForMaxScroll?: number, ++ startInsetCompensation: number = 0, + ): number => { + "worklet"; + +@@ -262,7 +263,14 @@ export const computeIOSContentOffset = ( + ); + } + +- const maxScroll = Math.max(contentHeight - layoutHeight + paddingForMax, 0); ++ const minScroll = -startInsetCompensation; ++ const maxScroll = Math.max( ++ contentHeight - layoutHeight + paddingForMax, ++ minScroll, ++ ); + +- return Math.min(Math.max(keyboardHeight + relativeScroll, 0), maxScroll); ++ return Math.min( ++ Math.max(keyboardHeight + relativeScroll, minScroll), ++ maxScroll, ++ ); + }; diff --git a/src/components/KeyboardChatScrollView/useChatKeyboard/index.ios.ts b/src/components/KeyboardChatScrollView/useChatKeyboard/index.ios.ts -index 560df54bae1a8c41a2e9ac0e2a8d2fd9b843968a..a0cb412692cf47fee372a01132e6a670b59bcd66 100644 +index 560df54bae1a8c41a2e9ac0e2a8d2fd9b843968a..c375a8c4cc889c2fc2c0c1034788c605633192ed 100644 --- a/src/components/KeyboardChatScrollView/useChatKeyboard/index.ios.ts +++ b/src/components/KeyboardChatScrollView/useChatKeyboard/index.ios.ts -@@ -43,6 +43,7 @@ function useChatKeyboard( +@@ -43,6 +43,8 @@ function useChatKeyboard( offset, blankSpace, extraContentPadding, + adjustedInsetCompensation, ++ adjustedStartInsetCompensation, } = options; const padding = useSharedValue(0); -@@ -104,10 +105,12 @@ function useChatKeyboard( +@@ -79,6 +81,10 @@ function useChatKeyboard( + offset, + ); + ++ if (e.duration === 0) { ++ currentHeight.value = effective; ++ } ++ + const atEnd = isScrollAtEnd( + scroll.value, + layout.value.height, +@@ -104,10 +110,12 @@ function useChatKeyboard( effective, minimumPaddingAbsorbed, ); @@ -408,50 +693,131 @@ index 560df54bae1a8c41a2e9ac0e2a8d2fd9b843968a..a0cb412692cf47fee372a01132e6a670 // persistent mode: when keyboard shrinks, clamp to valid range if ( -@@ -242,7 +245,7 @@ function useChatKeyboard( +@@ -130,11 +138,11 @@ function useChatKeyboard( + } else { + const maxScroll = Math.max( + size.value.height - layout.value.height + actualTotalPadding, +- 0, ++ -adjustedStartInsetCompensation, + ); + + contentOffsetY.value = Math.max( +- 0, ++ -adjustedStartInsetCompensation, + Math.min(scroll.value, maxScroll), + ); + } +@@ -165,11 +173,11 @@ function useChatKeyboard( + } else { + const maxScroll = Math.max( + size.value.height - layout.value.height + actualTotalPadding, +- 0, ++ -adjustedStartInsetCompensation, + ); + + contentOffsetY.value = Math.max( +- 0, ++ -adjustedStartInsetCompensation, + Math.min(scroll.value, maxScroll), + ); + } +@@ -219,12 +227,34 @@ function useChatKeyboard( + layout.value.height, + inverted, + actualTotalPadding, ++ adjustedStartInsetCompensation, + ); + }, +- onMove: () => { ++ onMove: (e) => { + "worklet"; + +- // iOS doesn't need per-frame updates (contentOffset handles it) ++ if (freeze.value) { ++ return; ++ } ++ ++ currentHeight.value = getEffectiveHeight( ++ e.height, ++ targetKeyboardHeight.value, ++ offset, ++ ); ++ }, ++ onInteractive: (e) => { ++ "worklet"; ++ ++ if (freeze.value) { ++ return; ++ } ++ ++ currentHeight.value = getEffectiveHeight( ++ e.height, ++ targetKeyboardHeight.value, ++ offset, ++ ); + }, + onEnd: (e) => { + "worklet"; +@@ -239,10 +269,18 @@ function useChatKeyboard( + offset, + ); + ++ currentHeight.value = effective; padding.value = effective; }, }, - [inverted, keyboardLiftBehavior, offset, extraContentPadding], -+ [inverted, keyboardLiftBehavior, offset, extraContentPadding, adjustedInsetCompensation], ++ [ ++ inverted, ++ keyboardLiftBehavior, ++ offset, ++ extraContentPadding, ++ adjustedInsetCompensation, ++ adjustedStartInsetCompensation, ++ ], ); return { diff --git a/src/components/KeyboardChatScrollView/useChatKeyboard/types.ts b/src/components/KeyboardChatScrollView/useChatKeyboard/types.ts -index 02abf5cd9490900826678175462b234e07e30e92..3f261fa8f1913a79fa1de2004bbac20415a89acc 100644 +index 02abf5cd9490900826678175462b234e07e30e92..4ee0ddd65e0b105568ab43b73997af4be8adae00 100644 --- a/src/components/KeyboardChatScrollView/useChatKeyboard/types.ts +++ b/src/components/KeyboardChatScrollView/useChatKeyboard/types.ts -@@ -11,6 +11,8 @@ type UseChatKeyboardOptions = { +@@ -11,6 +11,10 @@ type UseChatKeyboardOptions = { blankSpace: SharedValue; /** Extra content padding shared value — needed on iOS to correctly clamp contentOffset. */ extraContentPadding: SharedValue; + /** Extra bottom inset UIKit adds beyond the raw contentInset (safe area). Offset math only. */ + adjustedInsetCompensation: number; ++ /** Leading inset UIKit adds beyond the raw contentInset. Offset math only. */ ++ adjustedStartInsetCompensation: number; }; type UseChatKeyboardReturn = { diff --git a/src/components/KeyboardChatScrollView/useExtraContentPadding/index.ts b/src/components/KeyboardChatScrollView/useExtraContentPadding/index.ts -index 833acbe78f1b1245251ddd3431d6546decdd0ade..49d679446b03199217faa16a503d8d15e83a29b9 100644 +index 833acbe78f1b1245251ddd3431d6546decdd0ade..8f7c10cb6ab392f10df4ca43549b89c25844f0b8 100644 --- a/src/components/KeyboardChatScrollView/useExtraContentPadding/index.ts +++ b/src/components/KeyboardChatScrollView/useExtraContentPadding/index.ts -@@ -16,6 +16,8 @@ type UseExtraContentPaddingOptions = { +@@ -16,6 +16,10 @@ type UseExtraContentPaddingOptions = { keyboardPadding: SharedValue; /** Minimum inset floor — used to absorb keyboard and extraContentPadding changes. */ blankSpace: SharedValue; + /** Extra bottom inset UIKit adds beyond the raw contentInset (safe area). Offset math only. */ + adjustedInsetCompensation: number; ++ /** Leading inset UIKit adds beyond raw contentInset. Offset math only. */ ++ adjustedStartInsetCompensation: number; /** Current vertical scroll offset. */ scroll: SharedValue; /** Visible viewport dimensions. */ -@@ -49,6 +51,7 @@ function useExtraContentPadding(options: UseExtraContentPaddingOptions): void { +@@ -49,6 +53,8 @@ function useExtraContentPadding(options: UseExtraContentPaddingOptions): void { extraContentPadding, keyboardPadding, blankSpace, + adjustedInsetCompensation, ++ adjustedStartInsetCompensation, scroll, layout, size, -@@ -97,14 +100,12 @@ function useExtraContentPadding(options: UseExtraContentPaddingOptions): void { +@@ -97,14 +103,12 @@ function useExtraContentPadding(options: UseExtraContentPaddingOptions): void { } // Compute effective delta considering blankSpace floor @@ -472,12 +838,32 @@ index 833acbe78f1b1245251ddd3431d6546decdd0ade..49d679446b03199217faa16a503d8d15 const effectiveDelta = currentTotal - previousTotal; if (effectiveDelta === 0) { -@@ -146,7 +147,7 @@ function useExtraContentPadding(options: UseExtraContentPaddingOptions): void { +@@ -137,16 +141,25 @@ function useExtraContentPadding(options: UseExtraContentPaddingOptions): void { + + scrollToTarget(target); + } else { ++ const minScroll = -adjustedStartInsetCompensation; + const maxScroll = Math.max( + size.value.height - layout.value.height + currentTotal, +- 0, ++ minScroll, ++ ); ++ const target = Math.max( ++ minScroll, ++ Math.min(scroll.value + effectiveDelta, maxScroll), + ); +- const target = Math.min(scroll.value + effectiveDelta, maxScroll); + scrollToTarget(target); } }, - [inverted, keyboardLiftBehavior], -+ [inverted, keyboardLiftBehavior, adjustedInsetCompensation], ++ [ ++ inverted, ++ keyboardLiftBehavior, ++ adjustedInsetCompensation, ++ adjustedStartInsetCompensation, ++ ], ); } diff --git a/patches/react-native-screens@4.25.2.patch b/patches/react-native-screens@4.26.2.patch similarity index 75% rename from patches/react-native-screens@4.25.2.patch rename to patches/react-native-screens@4.26.2.patch index dc65d13b91bb..ff5168e8c692 100644 --- a/patches/react-native-screens@4.25.2.patch +++ b/patches/react-native-screens@4.26.2.patch @@ -1,8 +1,8 @@ diff --git a/android/src/main/java/com/swmansion/rnscreens/ScreenStackHeaderConfigViewManager.kt b/android/src/main/java/com/swmansion/rnscreens/ScreenStackHeaderConfigViewManager.kt -index 03d8d9f3a5c403c93282d53cbacf1a339e877e85..54d5eabc6c8a408e711a5be97286fdbb8ab1c461 100644 +index fa1b77317f609983ae2434764174072e51130550..270c81c90971b0ff3038b999973dd4d128a9d140 100644 --- a/android/src/main/java/com/swmansion/rnscreens/ScreenStackHeaderConfigViewManager.kt +++ b/android/src/main/java/com/swmansion/rnscreens/ScreenStackHeaderConfigViewManager.kt -@@ -103,6 +103,34 @@ class ScreenStackHeaderConfigViewManager : +@@ -124,6 +124,34 @@ class ScreenStackHeaderConfigViewManager : config.setTitle(title) } @@ -37,18 +37,37 @@ index 03d8d9f3a5c403c93282d53cbacf1a339e877e85..54d5eabc6c8a408e711a5be97286fdbb override fun setTitleFontFamily( config: ScreenStackHeaderConfig, titleFontFamily: String?, +diff --git a/android/src/main/java/com/swmansion/rnscreens/ScreenStackHeaderSubviewManager.kt b/android/src/main/java/com/swmansion/rnscreens/ScreenStackHeaderSubviewManager.kt +index 64f14f0c5ade16eb9519b8780d4ce013f88fbd3a..6cb1d69f97a95759c662a6006a9626925d9fefd9 100644 +--- a/android/src/main/java/com/swmansion/rnscreens/ScreenStackHeaderSubviewManager.kt ++++ b/android/src/main/java/com/swmansion/rnscreens/ScreenStackHeaderSubviewManager.kt +@@ -47,6 +47,12 @@ class ScreenStackHeaderSubviewManager : + Log.w("[RNScreens]", "hidesSharedBackground prop is not available on Android") + } + ++ // Bar button transition identifiers only apply on iOS. ++ override fun setIdentifier( ++ view: ScreenStackHeaderSubview, ++ identifier: String?, ++ ) = Unit ++ + // synchronousShadowStateUpdatesEnabled is not available on Android atm, + // however we must override their setters + override fun setSynchronousShadowStateUpdatesEnabled( diff --git a/ios/RNSBarButtonItem.h b/ios/RNSBarButtonItem.h -index ea5325ea8d17b1ddfa790ff8dab48ce83142d4d3..acd2fd7ceb7162ed300abc4d3c9f0c24f4d63898 100644 +index a26d1254eafd89268f65d968988cf6a3b8c1e457..62eea42ac26c532801189556c5f51012c3df3a52 100644 --- a/ios/RNSBarButtonItem.h +++ b/ios/RNSBarButtonItem.h -@@ -13,4 +13,8 @@ typedef void (^RNSBarButtonMenuItemAction)(NSString *menuId); +@@ -15,6 +15,10 @@ typedef void (^RNSBarButtonMenuItemAction)(NSString *menuId); + action:(RNSBarButtonItemAction)action menuAction:(RNSBarButtonMenuItemAction)menuAction imageLoader:(RCTImageLoader *)imageLoader; - ++ ++ (UIMenu *)initUIMenuWithDict:(NSDictionary *)dict + menuAction:(RNSBarButtonMenuItemAction)menuAction + imageLoader:(RCTImageLoader *)imageLoader; -+ + #endif // defined(__cplusplus) + @end diff --git a/ios/RNSBarButtonItem.mm b/ios/RNSBarButtonItem.mm index 0eb1f09dee82edd99e3e614938233db5cdeaebfe..73298f10807520970f625e166d7f5dd1338206f1 100644 @@ -114,10 +133,10 @@ index 0eb1f09dee82edd99e3e614938233db5cdeaebfe..73298f10807520970f625e166d7f5dd1 #if !TARGET_OS_TV || __TV_OS_VERSION_MAX_ALLOWED >= 170000 if (@available(tvOS 17.0, *)) { diff --git a/ios/RNSScreenStackHeaderConfig.h b/ios/RNSScreenStackHeaderConfig.h -index 919b984edc9f91ee9ac26faf257d8a721e26457c..5bb0cd6736ed6bc51db57e2a9326f758f22c51d8 100644 +index 8a41697223f6c4b5cb2dd6d450fc58cbb3b27536..309ba2f762068dcf452c172f7c7cbf2a55d03cb2 100644 --- a/ios/RNSScreenStackHeaderConfig.h +++ b/ios/RNSScreenStackHeaderConfig.h -@@ -21,6 +21,8 @@ +@@ -23,6 +23,8 @@ NS_ASSUME_NONNULL_BEGIN @property (nonatomic, retain) NSString *title; @@ -126,7 +145,7 @@ index 919b984edc9f91ee9ac26faf257d8a721e26457c..5bb0cd6736ed6bc51db57e2a9326f758 @property (nonatomic, retain) NSString *titleFontFamily; @property (nonatomic, retain) NSNumber *titleFontSize; @property (nonatomic, retain) NSString *titleFontWeight; -@@ -45,9 +47,12 @@ NS_ASSUME_NONNULL_BEGIN +@@ -47,9 +49,12 @@ NS_ASSUME_NONNULL_BEGIN @property (nonatomic) BOOL backButtonInCustomView; @property (nonatomic) UISemanticContentAttribute direction; @property (nonatomic) UINavigationItemBackButtonDisplayMode backButtonDisplayMode; @@ -140,10 +159,10 @@ index 919b984edc9f91ee9ac26faf257d8a721e26457c..5bb0cd6736ed6bc51db57e2a9326f758 NS_ASSUME_NONNULL_END diff --git a/ios/RNSScreenStackHeaderConfig.mm b/ios/RNSScreenStackHeaderConfig.mm -index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb2199dcbd58 100644 +index 1c844846a5c66e31cfa530ca462774d2fb6bbea1..a60b96b44e85d3f4b29dc0d190b95ab60e185033 100644 --- a/ios/RNSScreenStackHeaderConfig.mm +++ b/ios/RNSScreenStackHeaderConfig.mm -@@ -25,11 +25,33 @@ +@@ -24,11 +24,46 @@ #import "RNSSearchBar.h" #import "UINavigationBar+RNSUtility.h" @@ -153,10 +172,23 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21 static const NSNumber *const DEFAULT_TITLE_FONT_SIZE = @17; + -+// Keys for the last-applied JS bar button configs, associated with the -+// navigation item so unrelated header updates (title, subtitle, tint) don't -+// recreate the native buttons they configure. -+static char RNSAppliedHeaderBarButtonConfigsKey; ++#if !TARGET_OS_TV ++// Each placement keeps its own cache so changes to one side do not move live ++// items on the other side into new groups during a navigation transition. ++static char RNSAppliedLeadingHeaderItemsKey; ++static char RNSAppliedTrailingHeaderItemsKey; ++static char RNSAppliedCenterHeaderItemsKey; ++ ++// Records a placement's owner and contents, returning whether they changed. ++static BOOL RNSUpdateHeaderItemsCache(UINavigationItem *navitem, const void *key, NSArray *itemsKey) ++{ ++ if ([objc_getAssociatedObject(navitem, key) isEqual:itemsKey]) { ++ return NO; ++ } ++ objc_setAssociatedObject(navitem, key, itemsKey, OBJC_ASSOCIATION_RETAIN_NONATOMIC); ++ return YES; ++} ++#endif +static char RNSAppliedToolbarConfigsKey; static const NSNumber *const DEFAULT_TITLE_LARGE_FONT_SIZE = @34; @@ -177,7 +209,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21 @interface RCTImageLoader (Private) - (id)imageCache; @end -@@ -47,6 +69,9 @@ + (BOOL)rnscreens_isBlankOrNull:(NSString *)string +@@ -46,6 +81,9 @@ + (BOOL)rnscreens_isBlankOrNull:(NSString *)string @end @interface RNSScreenStackHeaderConfig () @@ -187,15 +219,15 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21 @end @implementation RNSScreenStackHeaderConfig { -@@ -81,6 +106,7 @@ - (void)initProps +@@ -80,6 +118,7 @@ - (void)initProps self.hidden = YES; _reactSubviews = [NSMutableArray new]; _backTitleVisible = YES; + _navigationItemStyle = UINavigationItemStyleNavigator; _blurEffect = RNSBlurEffectStyleNone; + _synchronousShadowStateUpdatesEnabled = YES; } - -@@ -496,6 +522,10 @@ + (void)updateViewController:(UIViewController *)vc +@@ -507,6 +546,10 @@ + (void)updateViewController:(UIViewController *)vc if (shouldHide) { navitem.title = config.title; @@ -206,7 +238,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21 // Setting navigation bar visibility is split to mitigate iOS 26 bug with bar button items. [navctr setNavigationBarHidden:YES animated:animated]; -@@ -512,11 +542,19 @@ + (void)updateViewController:(UIViewController *)vc +@@ -523,11 +566,19 @@ + (void)updateViewController:(UIViewController *)vc } navitem.largeTitleDisplayMode = config.largeTitle ? UINavigationItemLargeTitleDisplayModeAlways : UINavigationItemLargeTitleDisplayModeNever; @@ -226,7 +258,43 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21 // appearance does not apply to the tvOS so we need to use lagacy customization #if TARGET_OS_TV -@@ -637,10 +675,458 @@ + (void)updateViewController:(UIViewController *)vc +@@ -559,8 +610,6 @@ + (void)updateViewController:(UIViewController *)vc + navitem.leftItemsSupplementBackButton = config.backButtonInCustomView; + #endif + navitem.titleView = nil; +- navitem.leftBarButtonItems = nil; +- navitem.rightBarButtonItems = nil; + + #if !TARGET_OS_TV + // We want to set navitem.searchController to nil only if we are sure +@@ -568,22 +617,18 @@ + (void)updateViewController:(UIViewController *)vc + bool searchBarPresent = false; + #endif /* !TARGET_OS_TV */ + ++ NSMutableArray *subviewLeftItems = [NSMutableArray array]; ++ NSMutableArray *subviewRightItems = [NSMutableArray array]; + for (RNSScreenStackHeaderSubview *subview in config.reactSubviews) { + // This code should be kept in sync on Fabric with analogous switch statement in + // `- [RNSScreenStackHeaderConfig replaceNavigationBarViewsWithSnapshotOfSubview:]` method. + switch (subview.type) { + case RNSScreenStackHeaderSubviewTypeLeft: { +- NSArray *currentItems = navitem.leftBarButtonItems ?: @[]; +- NSMutableArray *mutableItems = [currentItems mutableCopy]; +- [mutableItems addObject:[subview getUIBarButtonItem]]; +- navitem.leftBarButtonItems = mutableItems; ++ [subviewLeftItems addObject:[subview getUIBarButtonItem]]; + break; + } + case RNSScreenStackHeaderSubviewTypeRight: { +- NSArray *currentItems = navitem.rightBarButtonItems ?: @[]; +- NSMutableArray *mutableItems = [currentItems mutableCopy]; +- [mutableItems addObject:[subview getUIBarButtonItem]]; +- navitem.rightBarButtonItems = mutableItems; ++ [subviewRightItems addObject:[subview getUIBarButtonItem]]; + break; + } + case RNSScreenStackHeaderSubviewTypeCenter: +@@ -648,10 +693,470 @@ + (void)updateViewController:(UIViewController *)vc // This assignment should be done after `navitem.titleView = ...` assignment (iOS 16.0 bug). // See: https://github.com/software-mansion/react-native-screens/issues/1570 (comments) navitem.title = config.title; @@ -234,69 +302,74 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21 - withCurrentItems:navitem.leftBarButtonItems]; - navitem.rightBarButtonItems = [config barButtonItemsFromConfigs:config.headerRightBarButtonItems - withCurrentItems:navitem.rightBarButtonItems]; -+ NSArray *headerLeftConfigs = config.headerLeftBarButtonItems ?: @[]; -+ NSArray *headerRightConfigs = config.headerRightBarButtonItems ?: @[]; -+ NSArray *headerCenterConfigs = config.headerCenterBarButtonItems ?: @[]; -+ NSArray *subviewLeftItems = navitem.leftBarButtonItems ?: @[]; -+ NSArray *subviewRightItems = navitem.rightBarButtonItems ?: @[]; -+ // The key includes the config instance's identity: cached items capture -+ // this config's event emitter in their press handlers, so a remounted -+ // header-config view with value-equal configs must still rebuild — reusing -+ // the old items would dispatch presses into the dead config's emitter. -+ NSArray *headerItemsKey = -+ @[ @((uintptr_t)config), headerLeftConfigs, headerRightConfigs, headerCenterConfigs ]; -+ // Rebuilding bar button items creates brand-new native buttons (glass -+ // UIButton custom views on iOS 26). Replacing them while UIKit animates an -+ // existing one (menu capsule morph, push/pop glass transitions) strands the -+ // animation overlay — a stuck expanded capsule or an unmasked square back -+ // button. When the JS configs are unchanged, keep the already-applied items. -+ // Subview-backed items are re-derived every pass, so their presence forces -+ // the rebuild path. -+ BOOL reuseHeaderBarButtonItems = subviewLeftItems.count == 0 && subviewRightItems.count == 0 && -+ [objc_getAssociatedObject(navitem, &RNSAppliedHeaderBarButtonConfigsKey) isEqual:headerItemsKey]; -+ if (!reuseHeaderBarButtonItems) { -+ NSArray *leftBarButtonItems = [config barButtonItemsFromConfigs:config.headerLeftBarButtonItems -+ withCurrentItems:navitem.leftBarButtonItems -+ navigationItem:navitem]; -+ NSArray *rightBarButtonItems = [config barButtonItemsFromConfigs:config.headerRightBarButtonItems -+ withCurrentItems:navitem.rightBarButtonItems -+ navigationItem:navitem]; -+ NSArray *centerBarButtonItems = [config barButtonItemsFromConfigs:config.headerCenterBarButtonItems -+ withCurrentItems:@[] -+ navigationItem:navitem]; ++ // Retain the config in the key so its address cannot be reused while cached. ++ // Items capture this config's event emitter in their press handlers, so a ++ // remounted header-config view with value-equal configs must still rebuild ++ // instead of dispatching presses into the old config's emitter. ++ // getUIBarButtonItem is stable for the lifetime of its React header ++ // subview. Include those native identities in the key so React-backed ++ // items can use the same cache without reusing a group after its view was ++ // replaced. ++ // A bar button item can belong to only one group. Even if the item itself ++ // is reused, constructing a new group removes it from the old group that ++ // UIKit may still be animating. Preserve each unchanged placement's group, ++ // including the empty leading group beside the system back button. +#if !TARGET_OS_TV -+ if (@available(iOS 16.0, *)) { -+ navitem.leadingItemGroups = [config barButtonItemGroupsFromItems:leftBarButtonItems]; -+ navitem.trailingItemGroups = [config barButtonItemGroupsFromItems:rightBarButtonItems]; -+ if (@available(iOS 26.0, *)) { -+ navitem.centerItemGroups = [config barButtonItemGroupsFromItems:centerBarButtonItems]; ++ if (@available(iOS 16.0, *)) { ++ NSArray *headerLeftConfigs = config.headerLeftBarButtonItems ?: @[]; ++ NSArray *headerRightConfigs = config.headerRightBarButtonItems ?: @[]; ++ NSArray *headerCenterConfigs = config.headerCenterBarButtonItems ?: @[]; ++ if (RNSUpdateHeaderItemsCache( ++ navitem, &RNSAppliedLeadingHeaderItemsKey, @[ config, headerLeftConfigs, subviewLeftItems ])) { ++ NSArray *items = [config barButtonItemsFromConfigs:config.headerLeftBarButtonItems ++ withCurrentItems:subviewLeftItems ++ navigationItem:navitem ++ allowsSearchBarPlacement:NO]; ++ navitem.leadingItemGroups = [config barButtonItemGroupsFromItems:items]; ++ } ++ if (RNSUpdateHeaderItemsCache( ++ navitem, &RNSAppliedTrailingHeaderItemsKey, @[ config, headerRightConfigs, subviewRightItems ])) { ++ NSArray *items = [config barButtonItemsFromConfigs:config.headerRightBarButtonItems ++ withCurrentItems:subviewRightItems ++ navigationItem:navitem ++ allowsSearchBarPlacement:NO]; ++ navitem.trailingItemGroups = [config barButtonItemGroupsFromItems:items]; ++ } ++ if (@available(iOS 26.0, *)) { ++ if (RNSUpdateHeaderItemsCache(navitem, &RNSAppliedCenterHeaderItemsKey, @[ config, headerCenterConfigs ])) { ++ NSArray *items = [config barButtonItemsFromConfigs:config.headerCenterBarButtonItems ++ withCurrentItems:@[] ++ navigationItem:navitem ++ allowsSearchBarPlacement:NO]; ++ navitem.centerItemGroups = [config barButtonItemGroupsFromItems:items]; + } -+ navitem.leftBarButtonItems = nil; -+ navitem.rightBarButtonItems = nil; -+ } else { -+ navitem.leftBarButtonItems = leftBarButtonItems; -+ navitem.rightBarButtonItems = rightBarButtonItems; + } -+#else ++ } else ++#endif ++ { ++ NSArray *leftBarButtonItems = [config barButtonItemsFromConfigs:config.headerLeftBarButtonItems ++ withCurrentItems:subviewLeftItems ++ navigationItem:navitem ++ allowsSearchBarPlacement:NO]; ++ NSArray *rightBarButtonItems = [config barButtonItemsFromConfigs:config.headerRightBarButtonItems ++ withCurrentItems:subviewRightItems ++ navigationItem:navitem ++ allowsSearchBarPlacement:NO]; + navitem.leftBarButtonItems = leftBarButtonItems; + navitem.rightBarButtonItems = rightBarButtonItems; -+#endif -+ // Only dict-driven items can be reused: with subview-backed items in the -+ // mix the applied state depends on view identity, so clear the key to -+ // force a rebuild on the next pass. -+ objc_setAssociatedObject( -+ navitem, -+ &RNSAppliedHeaderBarButtonConfigsKey, -+ subviewLeftItems.count == 0 && subviewRightItems.count == 0 ? headerItemsKey : nil, -+ OBJC_ASSOCIATION_RETAIN_NONATOMIC); + } + NSDictionary *mailSearchToolbarConfig = nil; ++ NSMutableArray *> *nonMailSearchToolbarConfigs = [NSMutableArray array]; + for (NSDictionary *toolbarConfig in config.headerToolbarItems) { + if (toolbarConfig[@"mailSearchToolbar"]) { -+ mailSearchToolbarConfig = toolbarConfig; -+ break; ++ if (mailSearchToolbarConfig == nil) { ++ mailSearchToolbarConfig = toolbarConfig; ++ } ++ } else { ++ [nonMailSearchToolbarConfigs addObject:toolbarConfig]; + } + } ++ NSArray *> *navigationToolbarConfigs = [nonMailSearchToolbarConfigs copy]; + if (mailSearchToolbarConfig == nil) { + for (NSDictionary *rightConfig in config.headerRightBarButtonItems) { + if ([rightConfig[@"bottomMailSearchToolbar"] boolValue]) { @@ -497,14 +570,6 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21 + searchField.attributedPlaceholder = + [[NSAttributedString alloc] initWithString:placeholder attributes:placeholderAttributes]; + } -+ NSString *searchTextChangeId = mailSearchToolbarConfig[@"searchTextChangeId"]; -+ if (searchTextChangeId != nil) { -+ [searchField addAction:[UIAction actionWithHandler:^(__kindof UIAction *_Nonnull action) { -+ UISearchTextField *field = (UISearchTextField *)action.sender; -+ emitButtonPress([NSString stringWithFormat:@"%@:%@", searchTextChangeId, field.text ?: @""]); -+ }] -+ forControlEvents:UIControlEventEditingChanged]; -+ } + searchField.borderStyle = UITextBorderStyleNone; + searchField.font = searchFont; + searchField.adjustsFontForContentSizeCategory = YES; @@ -521,6 +586,25 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21 + ]]; + } + ++ NSString *searchTextChangeId = mailSearchToolbarConfig[@"searchTextChangeId"]; ++ NSString *searchTextChangeActionIdentifier = @"org.react-native-screens.mail-search-toolbar.text-change"; ++ if (resolvedSearchTextField != nil) { ++ [resolvedSearchTextField removeActionForIdentifier:searchTextChangeActionIdentifier ++ forControlEvents:UIControlEventEditingChanged]; ++ } ++ if (searchTextChangeId != nil && resolvedSearchTextField != nil) { ++ [resolvedSearchTextField ++ addAction:[UIAction actionWithTitle:@"" ++ image:nil ++ identifier:searchTextChangeActionIdentifier ++ handler:^(__kindof UIAction *_Nonnull action) { ++ UITextField *field = (UITextField *)action.sender; ++ emitButtonPress( ++ [NSString stringWithFormat:@"%@:%@", searchTextChangeId, field.text ?: @""]); ++ }] ++ forControlEvents:UIControlEventEditingChanged]; ++ } ++ + UIButton *filterButton = nil; + if (hasFilterButton) { + filterButton = makeGlassButton( @@ -657,11 +741,6 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21 +#endif + } + -+ NSArray *> *navigationToolbarConfigs = config.headerToolbarItems; -+ if (mailSearchToolbarConfig != nil) { -+ navigationToolbarConfigs = @[]; -+ } -+ + NSArray *toolbarConfigsKey = navigationToolbarConfigs ?: @[]; + // Same reuse rule as the header item groups above (including the config + // identity — cached toolbar items capture this config's event emitter). @@ -675,7 +754,8 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21 + if (!reuseToolbarItems) { + NSArray *toolbarItems = [config barButtonItemsFromConfigs:navigationToolbarConfigs + withCurrentItems:@[] -+ navigationItem:navitem]; ++ navigationItem:navitem ++ allowsSearchBarPlacement:YES]; + if (toolbarItems.count > 0) { + vc.toolbarItems = toolbarItems; + [navctr setToolbarHidden:NO animated:animated]; @@ -689,15 +769,16 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21 // Setting navigation bar visibility is split to mitigate iOS 26 bug with bar button items // (setting nav bar visibility should be done after `navitem.*BarButtonItems`). -@@ -773,6 +1259,7 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController * +@@ -784,6 +1289,8 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController * - (NSArray *)barButtonItemsFromConfigs:(NSArray *> *)dicts withCurrentItems:(NSArray *)currentItems + navigationItem:(UINavigationItem *)navitem ++ allowsSearchBarPlacement:(BOOL)allowsSearchBarPlacement { if (dicts.count == 0) { return currentItems; -@@ -781,7 +1268,197 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController * +@@ -792,7 +1299,200 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController * [items addObjectsFromArray:currentItems]; for (NSUInteger i = 0; i < dicts.count; i++) { NSDictionary *dict = dicts[i]; @@ -833,6 +914,9 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21 + } +#endif + } else if (dict[@"searchBarPlacement"]) { ++ if (!allowsSearchBarPlacement) { ++ continue; ++ } +#if RNS_IPHONE_OS_VERSION_AVAILABLE(26_0) + if (@available(iOS 26.0, *)) { + NSNumber *width = dict[@"width"]; @@ -896,7 +980,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21 RNSBarButtonItem *item = [[RNSBarButtonItem alloc] initWithConfig:dict action:^(NSString *buttonId) { auto eventEmitter = std::static_pointer_cast( -@@ -803,19 +1480,23 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController * +@@ -814,19 +1514,23 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController * } imageLoader:_imageLoader]; NSNumber *index = dict[@"index"]; @@ -926,7 +1010,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21 [items insertObject:item atIndex:index.integerValue]; } else { [items addObject:item]; -@@ -825,6 +1506,47 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController * +@@ -836,6 +1540,47 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController * return items; } @@ -974,7 +1058,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21 RNS_IGNORE_SUPER_CALL_BEGIN - (void)insertReactSubview:(RNSScreenStackHeaderSubview *)subview atIndex:(NSInteger)atIndex { -@@ -1013,6 +1735,8 @@ - (void)updateProps:(react::Props::Shared const &)props oldProps:(react::Props:: +@@ -1023,6 +1768,8 @@ - (void)updateProps:(react::Props::Shared const &)props oldProps:(react::Props:: } _title = RCTNSStringFromStringNilIfEmpty(newScreenProps.title); @@ -983,7 +1067,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21 if (newScreenProps.titleFontFamily != oldScreenProps.titleFontFamily) { _titleFontFamily = RCTNSStringFromStringNilIfEmpty(newScreenProps.titleFontFamily); } -@@ -1038,6 +1762,7 @@ - (void)updateProps:(react::Props::Shared const &)props oldProps:(react::Props:: +@@ -1048,6 +1795,7 @@ - (void)updateProps:(react::Props::Shared const &)props oldProps:(react::Props:: _disableBackButtonMenu = newScreenProps.disableBackButtonMenu; _backButtonDisplayMode = [RNSConvert UINavigationItemBackButtonDisplayModeFromCppEquivalent:newScreenProps.backButtonDisplayMode]; @@ -991,7 +1075,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21 if (newScreenProps.userInterfaceStyle != oldScreenProps.userInterfaceStyle) { _userInterfaceStyle = [RNSConvert UIUserInterfaceStyleFromCppEquivalent:newScreenProps.userInterfaceStyle]; -@@ -1084,6 +1809,30 @@ - (void)updateProps:(react::Props::Shared const &)props oldProps:(react::Props:: +@@ -1094,6 +1842,30 @@ - (void)updateProps:(react::Props::Shared const &)props oldProps:(react::Props:: _headerRightBarButtonItems = array; } @@ -1022,12 +1106,98 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb21 [self updateViewControllerIfNeeded]; if (needsNavigationControllerLayout) { +diff --git a/ios/RNSScreenStackHeaderSubview.mm b/ios/RNSScreenStackHeaderSubview.mm +index add33c4807e038dcd846f6c72b2fc472ded02809..489afa5fa2da09c1ec6986d1832b58bb595396ae 100644 +--- a/ios/RNSScreenStackHeaderSubview.mm ++++ b/ios/RNSScreenStackHeaderSubview.mm +@@ -20,7 +20,9 @@ @implementation RNSScreenStackHeaderSubview { + // This is a strong reference to UIBarButtonItem which creates a retain cycle. + // The cycle is cleared via `invalidateUIBarButtonItem` method, called by `invalidate` callback. + UIBarButtonItem *_barButtonItem; ++ NSString *_barButtonItemIdentifier; + BOOL _hidesSharedBackground; ++ UILabel *_titleScrollEdgeEffectGuide; + } + + #pragma mark - Common +@@ -102,5 +104,24 @@ - (void)updateShadowStateWithFrame:(CGRect)frame + - (void)layoutSubviews + { + [super layoutSubviews]; ++#if RNS_IPHONE_OS_VERSION_AVAILABLE(26_0) ++ if (@available(iOS 26.0, *)) { ++ if (_type == RNSScreenStackHeaderSubviewTypeTitle || _type == RNSScreenStackHeaderSubviewTypeCenter) { ++ // UIKit shapes the navigation bar's scroll-edge fade around native labels, ++ // but does not recognize Fabric's custom text views. An empty, non-interactive ++ // label supplies the title's geometry without drawing or becoming a bar item. ++ if (_titleScrollEdgeEffectGuide == nil) { ++ _titleScrollEdgeEffectGuide = [UILabel new]; ++ _titleScrollEdgeEffectGuide.accessibilityElementsHidden = YES; ++ // Append after Fabric's children so their mount/unmount indices stay intact. ++ [self addSubview:_titleScrollEdgeEffectGuide]; ++ } ++ _titleScrollEdgeEffectGuide.frame = self.bounds; ++ } else { ++ [_titleScrollEdgeEffectGuide removeFromSuperview]; ++ _titleScrollEdgeEffectGuide = nil; ++ } ++ } ++#endif + [self updateShadowStateInContextOfAncestorView:[self findNavigationBar]]; + } +@@ -124,6 +145,7 @@ - (void)updateProps:(react::Props::Shared const &)props oldProps:(react::Props:: + const auto &newHeaderSubviewProps = *std::static_pointer_cast(props); + + [self setType:[RNSConvert RNSScreenStackHeaderSubviewTypeFromCppEquivalent:newHeaderSubviewProps.type]]; ++ [self setBarButtonItemIdentifier:RCTNSStringFromStringNilIfEmpty(newHeaderSubviewProps.identifier)]; + [self setHidesSharedBackground:newHeaderSubviewProps.hidesSharedBackground]; + [self setSynchronousShadowStateUpdatesEnabled:newHeaderSubviewProps.synchronousShadowStateUpdatesEnabled]; + [super updateProps:props oldProps:oldProps]; +@@ -276,7 +298,14 @@ - (void)configureBarButtonItem + #if RNS_IPHONE_OS_VERSION_AVAILABLE(26_0) + if (@available(iOS 26.0, *)) { + if (_barButtonItem != nil) { +- [_barButtonItem setHidesSharedBackground:_hidesSharedBackground]; ++ NSString *currentIdentifier = _barButtonItem.identifier; ++ if (currentIdentifier != _barButtonItemIdentifier && ++ ![currentIdentifier isEqualToString:_barButtonItemIdentifier]) { ++ _barButtonItem.identifier = _barButtonItemIdentifier; ++ } ++ if (_barButtonItem.hidesSharedBackground != _hidesSharedBackground) { ++ _barButtonItem.hidesSharedBackground = _hidesSharedBackground; ++ } + } + } + #endif // RNS_IPHONE_OS_VERSION_AVAILABLE(26_0) +@@ -284,10 +313,22 @@ - (void)configureBarButtonItem + + - (void)setHidesSharedBackground:(BOOL)hidesSharedBackground + { ++ if (_hidesSharedBackground == hidesSharedBackground) { ++ return; ++ } + _hidesSharedBackground = hidesSharedBackground; + [self configureBarButtonItem]; + } + ++- (void)setBarButtonItemIdentifier:(nullable NSString *)identifier ++{ ++ if (_barButtonItemIdentifier == identifier || [_barButtonItemIdentifier isEqualToString:identifier]) { ++ return; ++ } ++ _barButtonItemIdentifier = [identifier copy]; ++ [self configureBarButtonItem]; ++} ++ + #pragma mark - Dynamic frameworks support + + // Needed because of this: https://github.com/facebook/react-native/pull/37274 diff --git a/lib/commonjs/components/ScreenStackHeaderConfig.js b/lib/commonjs/components/ScreenStackHeaderConfig.js -index 16b979bb3dfb41ff247403f1632c300a9a60d549..01415d4cba66086a8d8e5af728f809aee5190d91 100644 +index 8c509f2657105cfd86448347c1a01fb73651ff9e..e668e089a1788973cb122a150be7846364a2a2cf 100644 --- a/lib/commonjs/components/ScreenStackHeaderConfig.js +++ b/lib/commonjs/components/ScreenStackHeaderConfig.js -@@ -23,18 +23,42 @@ const ScreenStackHeaderConfig = exports.ScreenStackHeaderConfig = /*#__PURE__*/_ - } = (0, _TopInsetApplicationContext.useTopInsetApplication)(!props.hidden, props.disableTopInsetApplication ?? false); +@@ -26,17 +26,42 @@ const ScreenStackHeaderConfig = exports.ScreenStackHeaderConfig = /*#__PURE__*/_ + } = (0, _EdgeInsetApplicationContext.useEdgeInsetApplication)(!props.hidden, props.disableTopInsetApplication ?? false, props.disableLeftInsetApplication ?? false, props.disableRightInsetApplication ?? false, props.disableBottomInsetApplication ?? false); const { headerLeftBarButtonItems, - headerRightBarButtonItems @@ -1038,8 +1208,8 @@ index 16b979bb3dfb41ff247403f1632c300a9a60d549..01415d4cba66086a8d8e5af728f809ae const preparedHeaderLeftBarButtonItems = headerLeftBarButtonItems && _utils.isHeaderBarButtonsAvailableForCurrentPlatform ? (0, _prepareHeaderBarButtonItems.prepareHeaderBarButtonItems)(headerLeftBarButtonItems, 'left') : undefined; const preparedHeaderRightBarButtonItems = headerRightBarButtonItems && _utils.isHeaderBarButtonsAvailableForCurrentPlatform ? (0, _prepareHeaderBarButtonItems.prepareHeaderBarButtonItems)(headerRightBarButtonItems, 'right') : undefined; - const hasHeaderBarButtonItems = _utils.isHeaderBarButtonsAvailableForCurrentPlatform && (preparedHeaderLeftBarButtonItems?.length || preparedHeaderRightBarButtonItems?.length); -+ const preparedHeaderCenterBarButtonItems = headerCenterBarButtonItems && _utils.isHeaderBarButtonsAvailableForCurrentPlatform ? (0, _prepareHeaderBarButtonItems.prepareHeaderBarButtonItems)(headerCenterBarButtonItems, 'right') : undefined; -+ const preparedHeaderToolbarItems = headerToolbarItems && _utils.isHeaderBarButtonsAvailableForCurrentPlatform ? (0, _prepareHeaderBarButtonItems.prepareHeaderBarButtonItems)(headerToolbarItems, 'right') : undefined; ++ const preparedHeaderCenterBarButtonItems = headerCenterBarButtonItems && _utils.isHeaderBarButtonsAvailableForCurrentPlatform ? (0, _prepareHeaderBarButtonItems.prepareHeaderBarButtonItems)(headerCenterBarButtonItems, 'center') : undefined; ++ const preparedHeaderToolbarItems = headerToolbarItems && _utils.isHeaderBarButtonsAvailableForCurrentPlatform ? (0, _prepareHeaderBarButtonItems.prepareHeaderBarButtonItems)(headerToolbarItems, 'toolbar') : undefined; + const hasHeaderBarButtonItems = _utils.isHeaderBarButtonsAvailableForCurrentPlatform && (preparedHeaderLeftBarButtonItems?.length || preparedHeaderRightBarButtonItems?.length || preparedHeaderCenterBarButtonItems?.length || preparedHeaderToolbarItems?.length); // Handle bar button item presses @@ -1050,7 +1220,8 @@ index 16b979bb3dfb41ff247403f1632c300a9a60d549..01415d4cba66086a8d8e5af728f809ae + const pressedItem = allItems.find(item => item && 'buttonId' in item && item.buttonId === buttonId); if (pressedItem && pressedItem.type === 'button' && pressedItem.onPress) { pressedItem.onPress(); - } ++ return; ++ } + for (const item of allItems) { + if (!item || item.type !== 'mailSearchToolbar') { + continue; @@ -1068,11 +1239,10 @@ index 16b979bb3dfb41ff247403f1632c300a9a60d549..01415d4cba66086a8d8e5af728f809ae + item.onSearchTextChange?.(buttonId.slice(searchTextChangePrefix.length)); + return; + } -+ } + } } : undefined; - // Handle bar button menu item presses by deep-searching nested menus -@@ -56,7 +80,7 @@ const ScreenStackHeaderConfig = exports.ScreenStackHeaderConfig = /*#__PURE__*/_ +@@ -59,7 +84,7 @@ const ScreenStackHeaderConfig = exports.ScreenStackHeaderConfig = /*#__PURE__*/_ }; // Check each bar-button item with a menu @@ -1081,7 +1251,7 @@ index 16b979bb3dfb41ff247403f1632c300a9a60d549..01415d4cba66086a8d8e5af728f809ae for (const item of allItems) { if (item && item.type === 'menu' && item.menu) { const action = findInMenu(item.menu, event.nativeEvent.menuId); -@@ -64,6 +88,15 @@ const ScreenStackHeaderConfig = exports.ScreenStackHeaderConfig = /*#__PURE__*/_ +@@ -67,6 +92,15 @@ const ScreenStackHeaderConfig = exports.ScreenStackHeaderConfig = /*#__PURE__*/_ action.onPress(); return; } @@ -1097,7 +1267,7 @@ index 16b979bb3dfb41ff247403f1632c300a9a60d549..01415d4cba66086a8d8e5af728f809ae } } } : undefined; -@@ -71,6 +104,8 @@ const ScreenStackHeaderConfig = exports.ScreenStackHeaderConfig = /*#__PURE__*/_ +@@ -74,6 +108,8 @@ const ScreenStackHeaderConfig = exports.ScreenStackHeaderConfig = /*#__PURE__*/_ userInterfaceStyle: props.experimental_userInterfaceStyle, headerLeftBarButtonItems: preparedHeaderLeftBarButtonItems, headerRightBarButtonItems: preparedHeaderRightBarButtonItems, @@ -1107,20 +1277,49 @@ index 16b979bb3dfb41ff247403f1632c300a9a60d549..01415d4cba66086a8d8e5af728f809ae onPressHeaderBarButtonMenuItem: onPressHeaderBarButtonMenuItem, ref: ref, diff --git a/lib/commonjs/components/helpers/prepareHeaderBarButtonItems.js b/lib/commonjs/components/helpers/prepareHeaderBarButtonItems.js -index ab93f62e4a7049d63c6681cecbd6cf8b1d07ce10..b5ea122473b023f84eabdb1914aef09a28c6d525 100644 +index ab93f62e4a7049d63c6681cecbd6cf8b1d07ce10..9dca2ab640e92161eb647b819a989acd0d41d9fe 100644 --- a/lib/commonjs/components/helpers/prepareHeaderBarButtonItems.js +++ b/lib/commonjs/components/helpers/prepareHeaderBarButtonItems.js -@@ -41,10 +41,31 @@ const prepareMenu = (menu, index, side, path = '') => { +@@ -5,7 +5,7 @@ Object.defineProperty(exports, "__esModule", { + }); + exports.prepareHeaderBarButtonItems = void 0; + var _reactNative = require("react-native"); +-const prepareMenu = (menu, index, side, path = '') => { ++const prepareMenu = (menu, index, placement, path = '') => { + return { + ...menu, + items: menu.items.map((menuItem, menuIndex) => { +@@ -26,7 +26,7 @@ const prepareMenu = (menu, index, side, path = '') => { + xcassetName, + imageSource, + templateSource, +- ...prepareMenu(menuItem, index, side, currentPath) ++ ...prepareMenu(menuItem, index, placement, currentPath) + }; + } + return { +@@ -35,16 +35,40 @@ const prepareMenu = (menu, index, side, path = '') => { + xcassetName, + imageSource, + templateSource, +- menuId: `${currentPath}-${index}-${side}` ++ menuId: `${currentPath}-${index}-${placement}` + }; + }) }; }; - const prepareHeaderBarButtonItems = (barButtonItems, side) => { +-const prepareHeaderBarButtonItems = (barButtonItems, side) => { - return barButtonItems?.map((item, index) => { ++const prepareHeaderBarButtonItems = (barButtonItems, placement) => { + const items = Array.isArray(barButtonItems) ? barButtonItems : barButtonItems && typeof barButtonItems === 'object' && 'type' in barButtonItems ? [barButtonItems] : undefined; + return items?.map((item, index) => { if (item.type === 'spacing') { return item; } + if (item.type === 'searchBarPlacement') { ++ if (placement !== 'toolbar') { ++ return null; ++ } + return { + ...item, + searchBarPlacement: true @@ -1136,19 +1335,41 @@ index ab93f62e4a7049d63c6681cecbd6cf8b1d07ce10..b5ea122473b023f84eabdb1914aef09a + return { + ...item, + mailSearchToolbar: true, -+ filterMenu: item.filterMenu ? prepareMenu(item.filterMenu, index, side, 'filter') : undefined, -+ composeMenu: item.composeMenu ? prepareMenu(item.composeMenu, index, side, 'compose') : undefined ++ filterMenu: item.filterMenu ? prepareMenu(item.filterMenu, index, placement, 'filter') : undefined, ++ composeMenu: item.composeMenu ? prepareMenu(item.composeMenu, index, placement, 'compose') : undefined + }; + } let imageSource, templateSource; if (item.icon?.type === 'imageSource') { imageSource = _reactNative.Image.resolveAssetSource(item.icon.imageSource); +@@ -77,17 +101,17 @@ const prepareHeaderBarButtonItems = (barButtonItems, side) => { + if (item.type === 'button') { + return { + ...processedItem, +- buttonId: `${index}-${side}` ++ buttonId: `${index}-${placement}` + }; + } + if (item.type === 'menu') { + return { + ...processedItem, +- menu: prepareMenu(item.menu, index, side) ++ menu: prepareMenu(item.menu, index, placement) + }; + } + return null; +- }); ++ }).filter(item => item !== null); + }; + exports.prepareHeaderBarButtonItems = prepareHeaderBarButtonItems; + //# sourceMappingURL=prepareHeaderBarButtonItems.js.map +\ No newline at end of file diff --git a/lib/module/components/ScreenStackHeaderConfig.js b/lib/module/components/ScreenStackHeaderConfig.js -index cf15f36f25e51d95c896fbc3a31ccba90156a5b6..dd66167c4ab0e5640ad27400e08dcfb4d20dce2a 100644 +index 68e3ba6a78314aba908d07098bb6982d11e6ee80..db5d17bfdac19ed3e664b55b6a73ce49766ac7c5 100644 --- a/lib/module/components/ScreenStackHeaderConfig.js +++ b/lib/module/components/ScreenStackHeaderConfig.js -@@ -19,18 +19,42 @@ export const ScreenStackHeaderConfig = /*#__PURE__*/React.forwardRef((props, ref - } = useTopInsetApplication(!props.hidden, props.disableTopInsetApplication ?? false); +@@ -22,17 +22,42 @@ export const ScreenStackHeaderConfig = /*#__PURE__*/React.forwardRef((props, ref + } = useEdgeInsetApplication(!props.hidden, props.disableTopInsetApplication ?? false, props.disableLeftInsetApplication ?? false, props.disableRightInsetApplication ?? false, props.disableBottomInsetApplication ?? false); const { headerLeftBarButtonItems, - headerRightBarButtonItems @@ -1159,8 +1380,8 @@ index cf15f36f25e51d95c896fbc3a31ccba90156a5b6..dd66167c4ab0e5640ad27400e08dcfb4 const preparedHeaderLeftBarButtonItems = headerLeftBarButtonItems && isHeaderBarButtonsAvailableForCurrentPlatform ? prepareHeaderBarButtonItems(headerLeftBarButtonItems, 'left') : undefined; const preparedHeaderRightBarButtonItems = headerRightBarButtonItems && isHeaderBarButtonsAvailableForCurrentPlatform ? prepareHeaderBarButtonItems(headerRightBarButtonItems, 'right') : undefined; - const hasHeaderBarButtonItems = isHeaderBarButtonsAvailableForCurrentPlatform && (preparedHeaderLeftBarButtonItems?.length || preparedHeaderRightBarButtonItems?.length); -+ const preparedHeaderCenterBarButtonItems = headerCenterBarButtonItems && isHeaderBarButtonsAvailableForCurrentPlatform ? prepareHeaderBarButtonItems(headerCenterBarButtonItems, 'right') : undefined; -+ const preparedHeaderToolbarItems = headerToolbarItems && isHeaderBarButtonsAvailableForCurrentPlatform ? prepareHeaderBarButtonItems(headerToolbarItems, 'right') : undefined; ++ const preparedHeaderCenterBarButtonItems = headerCenterBarButtonItems && isHeaderBarButtonsAvailableForCurrentPlatform ? prepareHeaderBarButtonItems(headerCenterBarButtonItems, 'center') : undefined; ++ const preparedHeaderToolbarItems = headerToolbarItems && isHeaderBarButtonsAvailableForCurrentPlatform ? prepareHeaderBarButtonItems(headerToolbarItems, 'toolbar') : undefined; + const hasHeaderBarButtonItems = isHeaderBarButtonsAvailableForCurrentPlatform && (preparedHeaderLeftBarButtonItems?.length || preparedHeaderRightBarButtonItems?.length || preparedHeaderCenterBarButtonItems?.length || preparedHeaderToolbarItems?.length); // Handle bar button item presses @@ -1171,7 +1392,8 @@ index cf15f36f25e51d95c896fbc3a31ccba90156a5b6..dd66167c4ab0e5640ad27400e08dcfb4 + const pressedItem = allItems.find(item => item && 'buttonId' in item && item.buttonId === buttonId); if (pressedItem && pressedItem.type === 'button' && pressedItem.onPress) { pressedItem.onPress(); - } ++ return; ++ } + for (const item of allItems) { + if (!item || item.type !== 'mailSearchToolbar') { + continue; @@ -1189,11 +1411,10 @@ index cf15f36f25e51d95c896fbc3a31ccba90156a5b6..dd66167c4ab0e5640ad27400e08dcfb4 + item.onSearchTextChange?.(buttonId.slice(searchTextChangePrefix.length)); + return; + } -+ } + } } : undefined; - // Handle bar button menu item presses by deep-searching nested menus -@@ -52,7 +76,7 @@ export const ScreenStackHeaderConfig = /*#__PURE__*/React.forwardRef((props, ref +@@ -55,7 +80,7 @@ export const ScreenStackHeaderConfig = /*#__PURE__*/React.forwardRef((props, ref }; // Check each bar-button item with a menu @@ -1202,7 +1423,7 @@ index cf15f36f25e51d95c896fbc3a31ccba90156a5b6..dd66167c4ab0e5640ad27400e08dcfb4 for (const item of allItems) { if (item && item.type === 'menu' && item.menu) { const action = findInMenu(item.menu, event.nativeEvent.menuId); -@@ -60,6 +84,15 @@ export const ScreenStackHeaderConfig = /*#__PURE__*/React.forwardRef((props, ref +@@ -63,6 +88,15 @@ export const ScreenStackHeaderConfig = /*#__PURE__*/React.forwardRef((props, ref action.onPress(); return; } @@ -1218,7 +1439,7 @@ index cf15f36f25e51d95c896fbc3a31ccba90156a5b6..dd66167c4ab0e5640ad27400e08dcfb4 } } } : undefined; -@@ -67,6 +100,8 @@ export const ScreenStackHeaderConfig = /*#__PURE__*/React.forwardRef((props, ref +@@ -70,6 +104,8 @@ export const ScreenStackHeaderConfig = /*#__PURE__*/React.forwardRef((props, ref userInterfaceStyle: props.experimental_userInterfaceStyle, headerLeftBarButtonItems: preparedHeaderLeftBarButtonItems, headerRightBarButtonItems: preparedHeaderRightBarButtonItems, @@ -1228,20 +1449,47 @@ index cf15f36f25e51d95c896fbc3a31ccba90156a5b6..dd66167c4ab0e5640ad27400e08dcfb4 onPressHeaderBarButtonMenuItem: onPressHeaderBarButtonMenuItem, ref: ref, diff --git a/lib/module/components/helpers/prepareHeaderBarButtonItems.js b/lib/module/components/helpers/prepareHeaderBarButtonItems.js -index 8a70ffd78617147418d628c03c580bb7ff9a8a72..8dbd0ba2ee61ee54d44cdf286a720ce26af01f26 100644 +index 8a70ffd78617147418d628c03c580bb7ff9a8a72..bca930372d2a7c3f8e0a35c3dcf28474f899f21c 100644 --- a/lib/module/components/helpers/prepareHeaderBarButtonItems.js +++ b/lib/module/components/helpers/prepareHeaderBarButtonItems.js -@@ -35,10 +35,31 @@ const prepareMenu = (menu, index, side, path = '') => { +@@ -1,5 +1,5 @@ + import { Image, processColor } from 'react-native'; +-const prepareMenu = (menu, index, side, path = '') => { ++const prepareMenu = (menu, index, placement, path = '') => { + return { + ...menu, + items: menu.items.map((menuItem, menuIndex) => { +@@ -20,7 +20,7 @@ const prepareMenu = (menu, index, side, path = '') => { + xcassetName, + imageSource, + templateSource, +- ...prepareMenu(menuItem, index, side, currentPath) ++ ...prepareMenu(menuItem, index, placement, currentPath) + }; + } + return { +@@ -29,16 +29,40 @@ const prepareMenu = (menu, index, side, path = '') => { + xcassetName, + imageSource, + templateSource, +- menuId: `${currentPath}-${index}-${side}` ++ menuId: `${currentPath}-${index}-${placement}` + }; + }) }; }; - export const prepareHeaderBarButtonItems = (barButtonItems, side) => { +-export const prepareHeaderBarButtonItems = (barButtonItems, side) => { - return barButtonItems?.map((item, index) => { ++export const prepareHeaderBarButtonItems = (barButtonItems, placement) => { + const items = Array.isArray(barButtonItems) ? barButtonItems : barButtonItems && typeof barButtonItems === 'object' && 'type' in barButtonItems ? [barButtonItems] : undefined; + return items?.map((item, index) => { if (item.type === 'spacing') { return item; } + if (item.type === 'searchBarPlacement') { ++ if (placement !== 'toolbar') { ++ return null; ++ } + return { + ...item, + searchBarPlacement: true @@ -1257,15 +1505,55 @@ index 8a70ffd78617147418d628c03c580bb7ff9a8a72..8dbd0ba2ee61ee54d44cdf286a720ce2 + return { + ...item, + mailSearchToolbar: true, -+ filterMenu: item.filterMenu ? prepareMenu(item.filterMenu, index, side, 'filter') : undefined, -+ composeMenu: item.composeMenu ? prepareMenu(item.composeMenu, index, side, 'compose') : undefined ++ filterMenu: item.filterMenu ? prepareMenu(item.filterMenu, index, placement, 'filter') : undefined, ++ composeMenu: item.composeMenu ? prepareMenu(item.composeMenu, index, placement, 'compose') : undefined + }; + } let imageSource, templateSource; if (item.icon?.type === 'imageSource') { imageSource = Image.resolveAssetSource(item.icon.imageSource); +@@ -71,16 +95,16 @@ export const prepareHeaderBarButtonItems = (barButtonItems, side) => { + if (item.type === 'button') { + return { + ...processedItem, +- buttonId: `${index}-${side}` ++ buttonId: `${index}-${placement}` + }; + } + if (item.type === 'menu') { + return { + ...processedItem, +- menu: prepareMenu(item.menu, index, side) ++ menu: prepareMenu(item.menu, index, placement) + }; + } + return null; +- }); ++ }).filter(item => item !== null); + }; + //# sourceMappingURL=prepareHeaderBarButtonItems.js.map +\ No newline at end of file +diff --git a/lib/typescript/components/helpers/prepareHeaderBarButtonItems.d.ts b/lib/typescript/components/helpers/prepareHeaderBarButtonItems.d.ts +index c41ef943e554754f599f2fcd157dd0dc61d78b7e..ed02d44f78ee3f1387ba93bf292be2b99bb0e3b3 100644 +--- a/lib/typescript/components/helpers/prepareHeaderBarButtonItems.d.ts ++++ b/lib/typescript/components/helpers/prepareHeaderBarButtonItems.d.ts +@@ -1,5 +1,5 @@ + import { HeaderBarButtonItem } from 'react-native-screens/types'; +-export declare const prepareHeaderBarButtonItems: (barButtonItems: HeaderBarButtonItem[], side: "left" | "right") => (import("react-native-screens/types").HeaderBarButtonItemSpacing | { ++export declare const prepareHeaderBarButtonItems: (barButtonItems: HeaderBarButtonItem[], side: "left" | "right" | "center" | "toolbar") => (import("react-native-screens/types").HeaderBarButtonItemSpacing | { + buttonId: string; + imageSource: import("react-native").ImageResolvedAssetSource | undefined; + templateSource: import("react-native").ImageResolvedAssetSource | undefined; +@@ -161,5 +161,5 @@ export declare const prepareHeaderBarButtonItems: (barButtonItems: HeaderBarButt + identifier?: string | undefined; + accessibilityLabel?: string | undefined; + accessibilityHint?: string | undefined; +-} | null)[]; ++})[]; + //# sourceMappingURL=prepareHeaderBarButtonItems.d.ts.map +\ No newline at end of file diff --git a/lib/typescript/fabric/ScreenStackHeaderConfigNativeComponent.d.ts b/lib/typescript/fabric/ScreenStackHeaderConfigNativeComponent.d.ts -index b7568ecfebd4f4420f2a8d3fec5184d7fc728dd1..41ca1f273f0175929e73105faa463978323837e3 100644 +index a51d8648567cff29cd2be0d5262bd15c6fc69c61..8b7439aff4d7e6fe8a72bf0bac93c3c19568b1a6 100644 --- a/lib/typescript/fabric/ScreenStackHeaderConfigNativeComponent.d.ts +++ b/lib/typescript/fabric/ScreenStackHeaderConfigNativeComponent.d.ts @@ -9,6 +9,7 @@ type OnPressHeaderBarButtonMenuItemEvent = Readonly<{ @@ -1299,9 +1587,21 @@ index b7568ecfebd4f4420f2a8d3fec5184d7fc728dd1..41ca1f273f0175929e73105faa463978 + headerToolbarItems?: CT.UnsafeMixed[] | undefined; onPressHeaderBarButtonItem?: CT.DirectEventHandler | undefined; onPressHeaderBarButtonMenuItem?: CT.DirectEventHandler | undefined; - synchronousShadowStateUpdatesEnabled?: CT.WithDefault; + synchronousShadowStateUpdatesEnabled?: CT.WithDefault; +diff --git a/lib/typescript/fabric/ScreenStackHeaderSubviewNativeComponent.d.ts b/lib/typescript/fabric/ScreenStackHeaderSubviewNativeComponent.d.ts +index 1ca926e6753294861f5f79ab6267dc7999f70639..94e676fdb08ff9d1cf81a1cf06a45e931b5461f1 100644 +--- a/lib/typescript/fabric/ScreenStackHeaderSubviewNativeComponent.d.ts ++++ b/lib/typescript/fabric/ScreenStackHeaderSubviewNativeComponent.d.ts +@@ -3,6 +3,7 @@ export type HeaderSubviewTypes = 'back' | 'right' | 'left' | 'title' | 'center' + export interface NativeProps extends ViewProps { + type?: CT.WithDefault; + hidesSharedBackground?: boolean | undefined; ++ identifier?: string | undefined; + synchronousShadowStateUpdatesEnabled?: CT.WithDefault; + } + declare const _default: import("react-native").HostComponent; diff --git a/lib/typescript/types.d.ts b/lib/typescript/types.d.ts -index 3b384e03891e38e936f370372a682d73440e7ec2..861ffed850e90f27916c427853b503dbe6ee9840 100644 +index 10a31341f4eff258a7f8ea6abae3eddf0dd21218..bab5031f5f9b246f15e5396f3f2fee90b91d826a 100644 --- a/lib/typescript/types.d.ts +++ b/lib/typescript/types.d.ts @@ -10,6 +10,7 @@ export type SearchBarCommands = { @@ -1375,7 +1675,21 @@ index 3b384e03891e38e936f370372a682d73440e7ec2..861ffed850e90f27916c427853b503db /** * Allows for setting text color of the title. */ -@@ -1001,6 +1044,11 @@ interface SharedHeaderBarButtonItem { +@@ -999,6 +1042,13 @@ export interface SearchBarProps { + shouldShowHintSearchIcon?: boolean | undefined; + } + export interface ScreenStackHeaderSubviewProps { ++ /** ++ * An identifier used to match this item across navigation bar transitions. ++ * Only applicable to type="right" and type="left" subviews on iOS 26.0 and later. ++ * ++ * Read more: https://developer.apple.com/documentation/uikit/uibarbuttonitem/identifier ++ */ ++ identifier?: string | undefined; + /** + * A boolean value indicating whether the background this item may share with other items in the bar should be hidden. + * Only applicable to type="right" and type="left" subviews. +@@ -1037,6 +1087,11 @@ interface SharedHeaderBarButtonItem { * Read more: https://developer.apple.com/documentation/uikit/uibarbuttonitem/style-swift.property */ variant?: 'plain' | 'done' | 'prominent' | undefined; @@ -1387,7 +1701,7 @@ index 3b384e03891e38e936f370372a682d73440e7ec2..861ffed850e90f27916c427853b503db /** * The tint color to apply to the item. * -@@ -1145,8 +1193,38 @@ export interface HeaderBarButtonItemWithMenu extends SharedHeaderBarButtonItem { +@@ -1181,8 +1236,38 @@ export interface HeaderBarButtonItemWithMenu extends SharedHeaderBarButtonItem { export interface HeaderBarButtonItemSpacing { type: 'spacing'; spacing: number; @@ -1428,11 +1742,11 @@ index 3b384e03891e38e936f370372a682d73440e7ec2..861ffed850e90f27916c427853b503db * Custom Screen Transition */ diff --git a/src/components/ScreenStackHeaderConfig.tsx b/src/components/ScreenStackHeaderConfig.tsx -index 421b3c2545426ae957271bd6515bcf827437541c..0ca6f1d7f324eaf257891a3bbb0aab210aa30f6f 100644 +index c3a88b6f6c8362cf717372a35cdc35a6b397bdd0..9e8173da567933fd3f6819fb26bf4a0e87640767 100644 --- a/src/components/ScreenStackHeaderConfig.tsx +++ b/src/components/ScreenStackHeaderConfig.tsx -@@ -39,7 +39,12 @@ export const ScreenStackHeaderConfig = React.forwardRef< - props.disableTopInsetApplication ?? false, +@@ -48,7 +48,12 @@ export const ScreenStackHeaderConfig = React.forwardRef< + props.disableBottomInsetApplication ?? false, ); - const { headerLeftBarButtonItems, headerRightBarButtonItems } = props; @@ -1445,17 +1759,17 @@ index 421b3c2545426ae957271bd6515bcf827437541c..0ca6f1d7f324eaf257891a3bbb0aab21 const preparedHeaderLeftBarButtonItems = headerLeftBarButtonItems && isHeaderBarButtonsAvailableForCurrentPlatform -@@ -49,22 +54,36 @@ export const ScreenStackHeaderConfig = React.forwardRef< +@@ -58,22 +63,36 @@ export const ScreenStackHeaderConfig = React.forwardRef< headerRightBarButtonItems && isHeaderBarButtonsAvailableForCurrentPlatform ? prepareHeaderBarButtonItems(headerRightBarButtonItems, 'right') : undefined; + const preparedHeaderCenterBarButtonItems = + headerCenterBarButtonItems && isHeaderBarButtonsAvailableForCurrentPlatform -+ ? prepareHeaderBarButtonItems(headerCenterBarButtonItems, 'right') ++ ? prepareHeaderBarButtonItems(headerCenterBarButtonItems, 'center') + : undefined; + const preparedHeaderToolbarItems = + headerToolbarItems && isHeaderBarButtonsAvailableForCurrentPlatform -+ ? prepareHeaderBarButtonItems(headerToolbarItems, 'right') ++ ? prepareHeaderBarButtonItems(headerToolbarItems, 'toolbar') + : undefined; const hasHeaderBarButtonItems = isHeaderBarButtonsAvailableForCurrentPlatform && @@ -1486,10 +1800,12 @@ index 421b3c2545426ae957271bd6515bcf827437541c..0ca6f1d7f324eaf257891a3bbb0aab21 ); if ( pressedItem && -@@ -73,6 +92,31 @@ export const ScreenStackHeaderConfig = React.forwardRef< +@@ -81,6 +100,32 @@ export const ScreenStackHeaderConfig = React.forwardRef< + pressedItem.onPress ) { pressedItem.onPress(); - } ++ return; ++ } + for (const item of allItems) { + if (!item || item.type !== 'mailSearchToolbar') { + continue; @@ -1514,11 +1830,10 @@ index 421b3c2545426ae957271bd6515bcf827437541c..0ca6f1d7f324eaf257891a3bbb0aab21 + ); + return; + } -+ } + } } : undefined; - -@@ -102,6 +146,8 @@ export const ScreenStackHeaderConfig = React.forwardRef< +@@ -111,6 +156,8 @@ export const ScreenStackHeaderConfig = React.forwardRef< const allItems = [ ...(preparedHeaderLeftBarButtonItems ?? []), ...(preparedHeaderRightBarButtonItems ?? []), @@ -1527,7 +1842,7 @@ index 421b3c2545426ae957271bd6515bcf827437541c..0ca6f1d7f324eaf257891a3bbb0aab21 ]; for (const item of allItems) { if (item && item.type === 'menu' && item.menu) { -@@ -110,6 +156,17 @@ export const ScreenStackHeaderConfig = React.forwardRef< +@@ -119,6 +166,17 @@ export const ScreenStackHeaderConfig = React.forwardRef< action.onPress(); return; } @@ -1545,7 +1860,7 @@ index 421b3c2545426ae957271bd6515bcf827437541c..0ca6f1d7f324eaf257891a3bbb0aab21 } } } -@@ -121,6 +178,8 @@ export const ScreenStackHeaderConfig = React.forwardRef< +@@ -130,6 +188,8 @@ export const ScreenStackHeaderConfig = React.forwardRef< userInterfaceStyle={props.experimental_userInterfaceStyle} headerLeftBarButtonItems={preparedHeaderLeftBarButtonItems} headerRightBarButtonItems={preparedHeaderRightBarButtonItems} @@ -1555,20 +1870,47 @@ index 421b3c2545426ae957271bd6515bcf827437541c..0ca6f1d7f324eaf257891a3bbb0aab21 onPressHeaderBarButtonMenuItem={onPressHeaderBarButtonMenuItem} ref={ref} diff --git a/src/components/helpers/prepareHeaderBarButtonItems.ts b/src/components/helpers/prepareHeaderBarButtonItems.ts -index be2c24dfee77f5883b5ab1d7473be80933b5dc6f..0b038d2005b2d51bbb2ab1d7dba7eaccda83d42f 100644 +index be2c24dfee77f5883b5ab1d7473be80933b5dc6f..2008a342f13806641909c19a9f4ab6829f239016 100644 --- a/src/components/helpers/prepareHeaderBarButtonItems.ts +++ b/src/components/helpers/prepareHeaderBarButtonItems.ts -@@ -50,13 +50,43 @@ const prepareMenu = ( +@@ -7,7 +7,7 @@ import { + const prepareMenu = ( + menu: HeaderBarButtonItemWithMenu['menu'], + index: number, +- side: 'left' | 'right', ++ placement: 'left' | 'right' | 'center' | 'toolbar', + path: string = '', + ): HeaderBarButtonItemWithMenu['menu'] => { + return { +@@ -34,7 +34,7 @@ const prepareMenu = ( + xcassetName, + imageSource, + templateSource, +- ...prepareMenu(menuItem, index, side, currentPath), ++ ...prepareMenu(menuItem, index, placement, currentPath), + }; + } + return { +@@ -43,20 +43,53 @@ const prepareMenu = ( + xcassetName, + imageSource, + templateSource, +- menuId: `${currentPath}-${index}-${side}`, ++ menuId: `${currentPath}-${index}-${placement}`, + }; + }), + }; }; export const prepareHeaderBarButtonItems = ( - barButtonItems: HeaderBarButtonItem[], +- side: 'left' | 'right', + barButtonItems: + | HeaderBarButtonItem[] + | HeaderBarButtonItem + | null + | undefined, - side: 'left' | 'right', ++ placement: 'left' | 'right' | 'center' | 'toolbar', ) => { - return barButtonItems?.map((item, index) => { + const items = Array.isArray(barButtonItems) @@ -1582,6 +1924,9 @@ index be2c24dfee77f5883b5ab1d7473be80933b5dc6f..0b038d2005b2d51bbb2ab1d7dba7eacc return item; } + if (item.type === 'searchBarPlacement') { ++ if (placement !== 'toolbar') { ++ return null; ++ } + return { + ...item, + searchBarPlacement: true, @@ -1597,15 +1942,34 @@ index be2c24dfee77f5883b5ab1d7473be80933b5dc6f..0b038d2005b2d51bbb2ab1d7dba7eacc + return { + ...item, + mailSearchToolbar: true, -+ filterMenu: item.filterMenu ? prepareMenu(item.filterMenu, index, side, 'filter') : undefined, -+ composeMenu: item.composeMenu ? prepareMenu(item.composeMenu, index, side, 'compose') : undefined, ++ filterMenu: item.filterMenu ? prepareMenu(item.filterMenu, index, placement, 'filter') : undefined, ++ composeMenu: item.composeMenu ? prepareMenu(item.composeMenu, index, placement, 'compose') : undefined, + }; + } let imageSource, templateSource; if (item.icon?.type === 'imageSource') { imageSource = Image.resolveAssetSource(item.icon.imageSource); +@@ -91,15 +124,15 @@ export const prepareHeaderBarButtonItems = ( + if (item.type === 'button') { + return { + ...processedItem, +- buttonId: `${index}-${side}`, ++ buttonId: `${index}-${placement}`, + }; + } + if (item.type === 'menu') { + return { + ...processedItem, +- menu: prepareMenu(item.menu, index, side), ++ menu: prepareMenu(item.menu, index, placement), + }; + } + return null; +- }); ++ }).filter(item => item !== null); + }; diff --git a/src/fabric/ScreenStackHeaderConfigNativeComponent.ts b/src/fabric/ScreenStackHeaderConfigNativeComponent.ts -index ba2479de0f49c112470f78c5084059ddd9e2f3e8..8677583a8f8daa4839340467466bfab8d73111ab 100644 +index a80d9fef0c0ab5ce8221c5e0c7fae65162bc6164..fc16f51425702a418c09de553a54753331f19d15 100644 --- a/src/fabric/ScreenStackHeaderConfigNativeComponent.ts +++ b/src/fabric/ScreenStackHeaderConfigNativeComponent.ts @@ -14,6 +14,7 @@ type OnPressHeaderBarButtonItemEvent = Readonly<{ buttonId: string }>; @@ -1641,8 +2005,20 @@ index ba2479de0f49c112470f78c5084059ddd9e2f3e8..8677583a8f8daa4839340467466bfab8 onPressHeaderBarButtonItem?: | CT.DirectEventHandler | undefined; +diff --git a/src/fabric/ScreenStackHeaderSubviewNativeComponent.ts b/src/fabric/ScreenStackHeaderSubviewNativeComponent.ts +index 7d3fbfe08c70461936832a40a1ce8d9c11b22457..1f9caca6271b68d182b70a180f180d6a4ce3988c 100644 +--- a/src/fabric/ScreenStackHeaderSubviewNativeComponent.ts ++++ b/src/fabric/ScreenStackHeaderSubviewNativeComponent.ts +@@ -14,6 +14,7 @@ export type HeaderSubviewTypes = + export interface NativeProps extends ViewProps { + type?: CT.WithDefault; + hidesSharedBackground?: boolean | undefined; ++ identifier?: string | undefined; + synchronousShadowStateUpdatesEnabled?: CT.WithDefault; + } + diff --git a/src/types.tsx b/src/types.tsx -index 76a83f3acb6fd3f0af7f027798848b7124100286..9e4499f076f9988e3266df4be7201e131d21242b 100644 +index 9c3ab4c3653ee1769addda8a6464cdf49695a601..6f242ad2bbd7152ea9f873ef7754ad47caf2466d 100644 --- a/src/types.tsx +++ b/src/types.tsx @@ -26,6 +26,7 @@ export type SearchBarCommands = { @@ -1716,7 +2092,21 @@ index 76a83f3acb6fd3f0af7f027798848b7124100286..9e4499f076f9988e3266df4be7201e13 /** * Allows for setting text color of the title. */ -@@ -1125,6 +1168,11 @@ interface SharedHeaderBarButtonItem { +@@ -1120,6 +1163,13 @@ export interface SearchBarProps { + } + + export interface ScreenStackHeaderSubviewProps { ++ /** ++ * An identifier used to match this item across navigation bar transitions. ++ * Only applicable to type="right" and type="left" subviews on iOS 26.0 and later. ++ * ++ * Read more: https://developer.apple.com/documentation/uikit/uibarbuttonitem/identifier ++ */ ++ identifier?: string | undefined; + /** + * A boolean value indicating whether the background this item may share with other items in the bar should be hidden. + * Only applicable to type="right" and type="left" subviews. +@@ -1161,6 +1211,11 @@ interface SharedHeaderBarButtonItem { * Read more: https://developer.apple.com/documentation/uikit/uibarbuttonitem/style-swift.property */ variant?: 'plain' | 'done' | 'prominent' | undefined; @@ -1728,7 +2118,7 @@ index 76a83f3acb6fd3f0af7f027798848b7124100286..9e4499f076f9988e3266df4be7201e13 /** * The tint color to apply to the item. * -@@ -1279,11 +1327,47 @@ export interface HeaderBarButtonItemWithMenu extends SharedHeaderBarButtonItem { +@@ -1315,11 +1370,47 @@ export interface HeaderBarButtonItemWithMenu extends SharedHeaderBarButtonItem { export interface HeaderBarButtonItemSpacing { type: 'spacing'; spacing: number; diff --git a/patches/uniwind@1.11.0.patch b/patches/uniwind@1.11.0.patch new file mode 100644 index 000000000000..6a5f4698bab5 --- /dev/null +++ b/patches/uniwind@1.11.0.patch @@ -0,0 +1,117 @@ +diff --git a/dist/metro/transformer.cjs b/dist/metro/transformer.cjs +index d57ca81..fd8693c 100644 +--- a/dist/metro/transformer.cjs ++++ b/dist/metro/transformer.cjs +@@ -7,6 +7,7 @@ const culori = require('culori'); + const node = require('@tailwindcss/node'); + const oxide = require('@tailwindcss/oxide'); + const fs = require('fs'); ++const node_crypto = require('node:crypto'); + + function _interopDefaultCompat (e) { return e && typeof e === 'object' && 'default' in e ? e.default : e; } + +@@ -1753,10 +1754,11 @@ const transform = async (config$1, projectRoot, filePath, data, options) => { + await bundlerConfig.generateArtifacts(cssArtifactPath); + const virtualCode = await compileCSS(bundlerConfig); + const isWeb = bundlerConfig.platform === config.Platform.Web; ++ const nativeStylesFingerprint = isWeb ? void 0 : node_crypto.createHash("sha256").update(virtualCode).update("\0").update(bundlerConfig.stringifiedThemes).digest("hex"); + data = Buffer.from( + isWeb ? virtualCode : [ + `const { Uniwind } = require('uniwind');`, +- `Uniwind.__reinit(rt => ${virtualCode}, ${bundlerConfig.stringifiedThemes});` ++ `Uniwind.__reinit(rt => ${virtualCode}, ${bundlerConfig.stringifiedThemes}, '${nativeStylesFingerprint}');` + ].join(""), + "utf-8" + ); +diff --git a/dist/metro/transformer.mjs b/dist/metro/transformer.mjs +index d3e7475..bf63587 100644 +--- a/dist/metro/transformer.mjs ++++ b/dist/metro/transformer.mjs +@@ -5,6 +5,7 @@ import { converter, parse, formatHex, formatHex8 } from 'culori'; + import { compile } from '@tailwindcss/node'; + import { Scanner } from '@tailwindcss/oxide'; + import fs from 'fs'; ++import { createHash } from 'node:crypto'; + + const toCamelCase = (str) => str.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase()); + const pipe = (data) => ((...fns) => fns.reduce((acc, fn) => fn(acc), data)); +@@ -1746,10 +1747,11 @@ const transform = async (config, projectRoot, filePath, data, options) => { + await bundlerConfig.generateArtifacts(cssArtifactPath); + const virtualCode = await compileCSS(bundlerConfig); + const isWeb = bundlerConfig.platform === Platform.Web; ++ const nativeStylesFingerprint = isWeb ? void 0 : createHash("sha256").update(virtualCode).update("\0").update(bundlerConfig.stringifiedThemes).digest("hex"); + data = Buffer.from( + isWeb ? virtualCode : [ + `const { Uniwind } = require('uniwind');`, +- `Uniwind.__reinit(rt => ${virtualCode}, ${bundlerConfig.stringifiedThemes});` ++ `Uniwind.__reinit(rt => ${virtualCode}, ${bundlerConfig.stringifiedThemes}, '${nativeStylesFingerprint}');` + ].join(""), + "utf-8" + ); +diff --git a/src/bundler/adapters/metro/transformer.ts b/src/bundler/adapters/metro/transformer.ts +index 08059b0..05c0515 100644 +--- a/src/bundler/adapters/metro/transformer.ts ++++ b/src/bundler/adapters/metro/transformer.ts +@@ -5,6 +5,7 @@ import { Platform } from '@/common/consts' + import type * as ExpoMetroConfig from '@expo/metro-config' + import type * as MetroTransformWorker from 'metro-transform-worker' + import type { JsTransformerConfig, JsTransformOptions } from 'metro-transform-worker' ++import { createHash } from 'node:crypto' + import path from 'path' + + const cssArtifactPath = path.resolve(__dirname, '../../uniwind.css') +@@ -69,13 +70,20 @@ export const transform = async ( + await bundlerConfig.generateArtifacts(cssArtifactPath) + const virtualCode = await compileCSS(bundlerConfig) + const isWeb = bundlerConfig.platform === Platform.Web ++ const nativeStylesFingerprint = isWeb ++ ? undefined ++ : createHash('sha256') ++ .update(virtualCode) ++ .update('\0') ++ .update(bundlerConfig.stringifiedThemes) ++ .digest('hex') + + data = Buffer.from( + isWeb + ? virtualCode + : [ + `const { Uniwind } = require('uniwind');`, +- `Uniwind.__reinit(rt => ${virtualCode}, ${bundlerConfig.stringifiedThemes});`, ++ `Uniwind.__reinit(rt => ${virtualCode}, ${bundlerConfig.stringifiedThemes}, '${nativeStylesFingerprint}');`, + ].join(''), + 'utf-8', + ) +diff --git a/src/core/config/config.native.ts b/src/core/config/config.native.ts +index 5e0832b..01aed1b 100644 +--- a/src/core/config/config.native.ts ++++ b/src/core/config/config.native.ts +@@ -8,6 +8,8 @@ import type { CSSVariables, GenerateStyleSheetsCallback, ThemeName } from '../ty + import { UniwindConfigBuilder as UniwindConfigBuilderBase } from './config.common' + + class UniwindConfigBuilder extends UniwindConfigBuilderBase { ++ private stylesFingerprint: string | undefined ++ + constructor() { + super() + } +@@ -35,9 +37,18 @@ class UniwindConfigBuilder extends UniwindConfigBuilderBase { + UniwindListener.notify([StyleDependency.Insets]) + } + +- protected __reinit(generateStyleSheetCallback: GenerateStyleSheetsCallback, themes: Array) { ++ protected __reinit( ++ generateStyleSheetCallback: GenerateStyleSheetsCallback, ++ themes: Array, ++ stylesFingerprint?: string, ++ ) { ++ if (__DEV__ && stylesFingerprint !== undefined && stylesFingerprint === this.stylesFingerprint) { ++ return ++ } ++ + super.__reinit(generateStyleSheetCallback, themes) + UniwindStore.reinit(generateStyleSheetCallback, themes) ++ this.stylesFingerprint = stylesFingerprint + } + + protected onThemeChange() { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6f456d7e65f9..099c8c006819 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -47,18 +47,18 @@ overrides: '@anthropic-ai/claude-agent-sdk>@anthropic-ai/claude-agent-sdk-win32-arm64': '-' '@anthropic-ai/claude-agent-sdk>@anthropic-ai/claude-agent-sdk-win32-x64': '-' '@clerk/backend': 3.14.0 - '@clerk/clerk-js': 6.29.2 + '@clerk/clerk-js': 6.30.1 '@clerk/clerk-js>@base-org/account': '-' '@clerk/clerk-js>@coinbase/wallet-sdk': '-' '@clerk/clerk-js>@solana/wallet-adapter-base': '-' '@clerk/clerk-js>@solana/wallet-adapter-react': '-' '@clerk/clerk-js>@solana/wallet-standard': '-' '@clerk/clerk-js>@wallet-standard/core': '-' - '@clerk/electron': 0.0.34 + '@clerk/electron': 0.0.37 '@clerk/electron-passkeys': 0.0.3 '@clerk/expo': 4.2.0 - '@clerk/react': 6.14.4 - '@clerk/shared': 4.29.2 + '@clerk/react': 6.14.7 + '@clerk/shared': 4.30.1 '@effect/atom-react': 4.0.0-beta.103 '@effect/platform-bun': 4.0.0-beta.103 '@effect/platform-node': 4.0.0-beta.103 @@ -67,34 +67,38 @@ overrides: '@effect/sql-sqlite-bun': 4.0.0-beta.103 '@effect/vitest': 4.0.0-beta.103 '@effect/vitest>vitest': '-' - '@expo/metro-config': 56.0.14 + '@expo/dom-webview': 57.0.1 + '@expo/metro-config': 57.0.12 + expo-constants: 57.0.16 '@pierre/diffs>@shikijs/transformers': ^4.2.0 '@types/node': 24.12.4 effect: 4.0.0-beta.103 - expo-modules-jsi: 56.0.10 - expo-sharing>@expo/config-plugins: 56.0.9 - expo-sharing>@expo/config-types: 56.0.6 + expo-router: 57.0.17 + expo-sharing>@expo/config-plugins: 57.0.9 + expo-sharing>@expo/config-types: 57.0.2 vite: npm:@voidzero-dev/vite-plus-core@0.2.2 yaml: ^2.9.0 -packageExtensionsChecksum: sha256-CUzzeefpj3gNFrCKNBhV9FOaniNbrLdKyIhWQyXuaiE= +packageExtensionsChecksum: sha256-k/dT9NFDl5hihRPaoFKeY11hzyutMFs5psfZLFiKJic= patchedDependencies: '@clerk/expo@4.2.0': 72e426f44fc1cde16fc2cbba3d1e96cdca7c6d957faa73d0fe6b43948608a6c1 '@effect/vitest@4.0.0-beta.103': a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b - '@expo/metro-config@56.0.14': 8cb08b5bb7051ed9d2dbe46a2c293c5a1e17f1bd6ddf30de27909e18c921ff46 + '@expo/metro-config@57.0.12': 96f1a75347e6ea02dc4b7034ace815d8ee39e18b8166ebfb573d9e58328f0dc2 '@ff-labs/fff-node@0.9.4': ab9ff544009e1891cfe3930105862d3699007f38922a79f3c98d90018deca368 - '@legendapp/list@3.3.5': 064530db83875fa671559a81ae42dc159726dc2dd6ec4c982da44ff7c7a74706 + '@legendapp/list@3.3.5': 03ec41339cd915ecb9a774a6b90cc2197c29038f7db67c4d2e55cd3971e5be43 '@pierre/diffs@1.3.0-beta.10': 7ef7cb0cbabb17c15cdb137554068b36f15f6f5265e73fb51452aa3380db91aa - '@react-native-menu/menu@2.0.0': c7f66d121c726ade4f5c4e1aed11a691e5711d244c544084e289ac26132a0045 - '@react-native/gradle-plugin@0.85.3': c1b594a16e682d621b6a960926f8ce13fc92edfb397884cfe7fcb0518996a784 - '@react-navigation/native-stack@7.17.6': 0365b727005b3a830af80ccbd0b637666cc0338d33ddcb7a25b6de51a21ea027 + '@react-native-ai/apple@0.12.0': 2d09870c2848d185cb05b53ed823a46e12dba519324d8dd8e584e28731990f9d + '@react-native-menu/menu@2.0.0': f63d256bf6a97a873b5e628eb595bd6ef0075ddd5bdd890fc920f7a6024290dd + '@react-navigation/native-stack@7.17.6': e667c3cef8c78bb9ff4882ee5bd23a432247b843060a9499eb5f07e9e2295552 effect@4.0.0-beta.103: af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6 - expo-modules-jsi@56.0.10: 9170f8074ae4e35a0a086e756c8f815794fd3abe51eac67ca3ba02804225ec1f - react-native-gesture-handler@2.31.2: 808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d3 - react-native-keyboard-controller@1.21.13: 20be72c84d74253acdcfefbc6defe36dc396944f1a44cab2bdd0e3cd572ae008 + expo-audio@57.0.4: fa9a3e0442ed395d4071bb406e08c3a471c9a84700bdfa0b9ad7ff144c96041a + expo-sharing@57.0.16: 8d2e3b10eb3f52036a9a086800180ec6cebf3b75bccc5b1775117a7244d4ac45 + react-native-gesture-handler@2.32.0: 808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d3 + react-native-keyboard-controller@1.21.13: 6e4339347bc5bb3c9ea67d85ff5c814058b211c5750f247aba59d07869a2e787 react-native-nitro-modules@0.35.9: 825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675 - react-native-screens@4.25.2: 59bfd7b84af01708b6e581c4ccdd5ecf05f8b205383802d66b64ed7ea7bb2199 + react-native-screens@4.26.2: 149bef30a66351ea9b26b42f87b78c539cb52b880f2387bb323ec80dcac84006 + uniwind@1.11.0: 329a77525509623d763b738152dbd00ab392cdcdd9fb6ed2b4a20e086e437196 importers: @@ -122,8 +126,8 @@ importers: apps/desktop: dependencies: '@clerk/electron': - specifier: 0.0.34 - version: 0.0.34(@clerk/electron-passkeys@0.0.3)(electron-store@8.2.0)(electron@41.5.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + specifier: 0.0.37 + version: 0.0.37(@clerk/electron-passkeys@0.0.3)(electron-store@8.2.0)(electron@43.4.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@clerk/electron-passkeys': specifier: 0.0.3 version: 0.0.3 @@ -149,8 +153,8 @@ importers: specifier: 4.0.0-beta.103 version: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) electron: - specifier: 41.5.0 - version: 41.5.0 + specifier: 43.4.1 + version: 43.4.1 electron-store: specifier: ^8.2.0 version: 8.2.0 @@ -170,6 +174,9 @@ importers: '@types/node': specifier: 24.12.4 version: 24.12.4 + acorn: + specifier: 8.16.0 + version: 8.16.0 cross-env: specifier: ^10.1.0 version: 10.1.0 @@ -204,12 +211,9 @@ importers: apps/mobile: dependencies: - '@callstack/liquid-glass': - specifier: ^0.7.1 - version: 0.7.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@clerk/expo': specifier: 4.2.0 - version: 4.2.0(patch_hash=72e426f44fc1cde16fc2cbba3d1e96cdca7c6d957faa73d0fe6b43948608a6c1)(expo-auth-session@56.0.14(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(expo-constants@56.0.18)(expo-crypto@56.0.4(expo@56.0.12))(expo-secure-store@56.0.4(expo@56.0.12))(expo-web-browser@56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.12)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3) + version: 4.2.0(patch_hash=72e426f44fc1cde16fc2cbba3d1e96cdca7c6d957faa73d0fe6b43948608a6c1)(1fcd0592788ddcf326eeeb90d875ed47) '@effect/atom-react': specifier: 4.0.0-beta.103 version: 4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(react@19.2.3)(scheduler@0.27.0) @@ -217,14 +221,14 @@ importers: specifier: ^0.4.2 version: 0.4.2 '@expo/metro-runtime': - specifier: ~56.0.15 - version: 56.0.15(@expo/log-box@56.0.13)(expo@56.0.12)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + specifier: ~57.0.14 + version: 57.0.14(@expo/log-box@57.0.4)(expo@57.0.18)(react-dom@19.2.3(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@expo/ui': - specifier: ~56.0.18 - version: 56.0.18(3cdc0dde9f93166d952f1e1bd0cb25c0) + specifier: ~57.0.14 + version: 57.0.14(@babel/core@7.29.7)(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(expo@57.0.18)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@legendapp/list': specifier: 'catalog:' - version: 3.3.5(patch_hash=064530db83875fa671559a81ae42dc159726dc2dd6ec4c982da44ff7c7a74706)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 3.3.5(patch_hash=03ec41339cd915ecb9a774a6b90cc2197c29038f7db67c4d2e55cd3971e5be43)(react-dom@19.2.3(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@noble/curves': specifier: 'catalog:' version: 1.9.1 @@ -234,18 +238,21 @@ importers: '@pierre/diffs': specifier: 'catalog:' version: 1.3.0-beta.10(patch_hash=7ef7cb0cbabb17c15cdb137554068b36f15f6f5265e73fb51452aa3380db91aa)(@shikijs/themes@4.2.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@react-native-ai/apple': + specifier: 0.12.0 + version: 0.12.0(patch_hash=2d09870c2848d185cb05b53ed823a46e12dba519324d8dd8e584e28731990f9d)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) '@react-native-menu/menu': specifier: ^2.0.0 - version: 2.0.0(patch_hash=c7f66d121c726ade4f5c4e1aed11a691e5711d244c544084e289ac26132a0045)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 2.0.0(patch_hash=f63d256bf6a97a873b5e628eb595bd6ef0075ddd5bdd890fc920f7a6024290dd)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@react-navigation/elements': specifier: 2.9.26 - version: 2.9.26(c6a2ad0e2c930f8e3896e77c3ba11bc4) + version: 2.9.26(c10301b6e0c42fc6434d2b643197a81e) '@react-navigation/native': specifier: 7.3.4 - version: 7.3.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 7.3.4(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@react-navigation/native-stack': specifier: 7.17.6 - version: 7.17.6(patch_hash=0365b727005b3a830af80ccbd0b637666cc0338d33ddcb7a25b6de51a21ea027)(7ffd26361d0ffb9781446d1519df37be) + version: 7.17.6(patch_hash=e667c3cef8c78bb9ff4882ee5bd23a432247b843060a9499eb5f07e9e2295552)(d307537762dff86bcf277a4ec64a11d8) '@shikijs/core': specifier: 4.2.0 version: 4.2.0 @@ -266,7 +273,7 @@ importers: version: link:../../packages/contracts '@t3tools/mobile-markdown-text': specifier: file:./modules/t3-markdown-text - version: file:apps/mobile/modules/t3-markdown-text(ed3009b8f2424467288a00b38bef28fe) + version: file:apps/mobile/modules/t3-markdown-text(cd0d4cdec0d5bee3af406af520908919) '@t3tools/mobile-review-diff-native': specifier: file:./modules/t3-review-diff version: file:apps/mobile/modules/t3-review-diff @@ -278,7 +285,7 @@ importers: version: link:../../packages/shared '@tabler/icons-react-native': specifier: ^3.44.0 - version: 3.44.0(react-native-svg@15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react@19.2.3) + version: 3.44.0(react-native-svg@15.15.4(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react@19.2.3) clsx: specifier: ^2.1.1 version: 2.1.1 @@ -289,95 +296,104 @@ importers: specifier: 4.0.0-beta.103 version: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) expo: - specifier: ~56.0.12 - version: 56.0.12(8895228379997a2a064f9644cda56ed0) + specifier: ~57.0.18 + version: 57.0.18(f9c992a5d7c53d81398568d3950992dc) expo-asset: - specifier: ~56.0.17 - version: 56.0.17(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3) + specifier: ~57.0.15 + version: 57.0.15(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3) + expo-audio: + specifier: ~57.0.4 + version: 57.0.4(patch_hash=fa9a3e0442ed395d4071bb406e08c3a471c9a84700bdfa0b9ad7ff144c96041a)(expo-asset@57.0.15(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3))(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) expo-auth-session: - specifier: ~56.0.14 - version: 56.0.14(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + specifier: ~57.0.10 + version: 57.0.10(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) expo-blur: - specifier: ~56.0.3 - version: 56.0.3(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + specifier: ~57.0.2 + version: 57.0.2(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) expo-build-properties: - specifier: ~56.0.19 - version: 56.0.19(expo@56.0.12) + specifier: ~57.0.15 + version: 57.0.15(expo@57.0.18) expo-camera: - specifier: ~56.0.8 - version: 56.0.8(@types/emscripten@1.41.5)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + specifier: ~57.0.4 + version: 57.0.4(@types/emscripten@1.41.5)(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) expo-clipboard: - specifier: ~56.0.4 - version: 56.0.4(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + specifier: ~57.0.1 + version: 57.0.1(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) expo-constants: - specifier: ~56.0.18 - version: 56.0.18(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + specifier: 57.0.16 + version: 57.0.16(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) expo-crypto: - specifier: ~56.0.4 - version: 56.0.4(expo@56.0.12) + specifier: ~57.0.2 + version: 57.0.2(expo@57.0.18) expo-dev-client: - specifier: ~56.0.20 - version: 56.0.20(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + specifier: ~57.0.16 + version: 57.0.16(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + expo-device: + specifier: ~57.0.1 + version: 57.0.1(expo@57.0.18) + expo-document-picker: + specifier: ~57.0.1 + version: 57.0.1(expo@57.0.18) expo-file-system: - specifier: ~56.0.8 - version: 56.0.8(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + specifier: ~57.0.6 + version: 57.0.6(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) expo-font: - specifier: ~56.0.7 - version: 56.0.7(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + specifier: ~57.0.2 + version: 57.0.2(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) expo-glass-effect: - specifier: ~56.0.4 - version: 56.0.4(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + specifier: ~57.0.1 + version: 57.0.1(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) expo-haptics: - specifier: ~56.0.3 - version: 56.0.3(expo@56.0.12) + specifier: ~57.0.2 + version: 57.0.2(expo@57.0.18) expo-image: - specifier: ~56.0.11 - version: 56.0.11(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + specifier: ~57.0.3 + version: 57.0.3(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) expo-image-picker: - specifier: ~56.0.18 - version: 56.0.18(expo@56.0.12) + specifier: ~57.0.14 + version: 57.0.14(expo@57.0.18) expo-linking: - specifier: ~56.0.14 - version: 56.0.14(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + specifier: ~57.0.8 + version: 57.0.8(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) expo-network: - specifier: ~56.0.5 - version: 56.0.5(expo@56.0.12)(react@19.2.3) + specifier: ~57.0.1 + version: 57.0.1(expo@57.0.18)(react@19.2.3) expo-notifications: - specifier: ~56.0.18 - version: 56.0.18(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3) + specifier: ~57.0.15 + version: 57.0.15(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3) expo-paste-input: specifier: ^0.1.15 - version: 0.1.15(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 0.1.15(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) expo-quick-actions: specifier: ^6.0.2 - version: 6.0.2(expo@56.0.12)(typescript@6.0.3) + version: 6.0.2(expo@57.0.18)(typescript@6.0.3) expo-secure-store: - specifier: ~56.0.4 - version: 56.0.4(expo@56.0.12) + specifier: ~57.0.2 + version: 57.0.2(expo@57.0.18) expo-sharing: - specifier: ~56.0.18 - version: 56.0.18(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3) + specifier: ~57.0.16 + version: 57.0.16(patch_hash=8d2e3b10eb3f52036a9a086800180ec6cebf3b75bccc5b1775117a7244d4ac45)(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3) expo-splash-screen: - specifier: ~56.0.10 - version: 56.0.10(expo@56.0.12)(typescript@6.0.3) + specifier: ~57.0.8 + version: 57.0.8(expo@57.0.18)(typescript@6.0.3) expo-sqlite: - specifier: ~56.0.5 - version: 56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + specifier: ~57.0.2 + version: 57.0.2(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) expo-symbols: - specifier: ~56.0.6 - version: 56.0.6(expo-font@56.0.7)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + specifier: ~57.0.2 + version: 57.0.2(expo-font@57.0.2(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) expo-updates: - specifier: ~56.0.19 - version: 56.0.19(expo-dev-client@56.0.20(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + specifier: ~57.0.19 + version: 57.0.19(expo-dev-client@57.0.16(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + expo-video: + specifier: ~57.0.3 + version: 57.0.3(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) expo-web-browser: - specifier: ~56.0.5 - version: 56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + specifier: ~57.0.2 + version: 57.0.2(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) expo-widgets: - specifier: ~56.0.19 - version: 56.0.19(3cdc0dde9f93166d952f1e1bd0cb25c0) - punycode: - specifier: ^2.3.1 - version: 2.3.1 + specifier: ~57.0.15 + version: 57.0.15(@babel/core@7.29.7)(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(expo@57.0.18)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react: specifier: 19.2.3 version: 19.2.3 @@ -385,44 +401,44 @@ importers: specifier: 19.2.3 version: 19.2.3(react@19.2.3) react-native: - specifier: 0.85.3 - version: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + specifier: 0.86.3 + version: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) react-native-gesture-handler: - specifier: ~2.31.1 - version: 2.31.2(patch_hash=808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d3)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + specifier: ~2.32.0 + version: 2.32.0(patch_hash=808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d3)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-image-viewing: specifier: ^0.2.2 - version: 0.2.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 0.2.2(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-keyboard-controller: specifier: 1.21.13 - version: 1.21.13(patch_hash=20be72c84d74253acdcfefbc6defe36dc396944f1a44cab2bdd0e3cd572ae008)(react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 1.21.13(patch_hash=6e4339347bc5bb3c9ea67d85ff5c814058b211c5750f247aba59d07869a2e787)(react-native-reanimated@4.5.1(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-nitro-markdown: specifier: ^0.5.0 - version: 0.5.8(react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-svg@15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 0.5.8(react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-svg@15.15.4(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-nitro-modules: specifier: 0.35.9 - version: 0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-reanimated: - specifier: 4.3.1 - version: 4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + specifier: 4.5.1 + version: 4.5.1(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-safe-area-context: specifier: ~5.7.0 - version: 5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 5.7.0(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-screens: - specifier: 4.25.2 - version: 4.25.2(patch_hash=59bfd7b84af01708b6e581c4ccdd5ecf05f8b205383802d66b64ed7ea7bb2199)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + specifier: ~4.26.0 + version: 4.26.2(patch_hash=149bef30a66351ea9b26b42f87b78c539cb52b880f2387bb323ec80dcac84006)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-shiki-engine: specifier: ^0.3.12 - version: 0.3.12(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 0.3.12(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-svg: specifier: 15.15.4 - version: 15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 15.15.4(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-webview: specifier: ^13.16.1 - version: 13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 13.16.1(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-worklets: - specifier: 0.8.3 - version: 0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + specifier: 0.10.1 + version: 0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) shiki: specifier: 4.2.0 version: 4.2.0 @@ -430,8 +446,8 @@ importers: specifier: ^3.5.0 version: 3.6.0 uniwind: - specifier: ^1.6.2 - version: 1.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(tailwindcss@4.3.0) + specifier: 1.11.0 + version: 1.11.0(patch_hash=329a77525509623d763b738152dbd00ab392cdcdd9fb6ed2b4a20e086e437196)(@expo/metro-config@57.0.12(patch_hash=96f1a75347e6ea02dc4b7034ace815d8ee39e18b8166ebfb573d9e58328f0dc2)(bufferutil@4.1.0)(expo@57.0.18)(typescript@6.0.3)(utf-8-validate@6.0.6))(metro-cache@0.84.5)(metro-transform-worker@0.84.5(bufferutil@4.1.0)(utf-8-validate@6.0.6))(metro@0.84.5(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(tailwindcss@4.3.0) devDependencies: '@effect/vitest': specifier: 4.0.0-beta.103 @@ -443,8 +459,8 @@ importers: specifier: ~19.2.0 version: 19.2.16 babel-preset-expo: - specifier: ~56.0.0 - version: 56.0.14(@babel/core@7.29.7)(@babel/runtime@7.29.7)(expo-widgets@56.0.19)(expo@56.0.12)(react-refresh@0.14.2) + specifier: ~57.0.9 + version: 57.0.9(@babel/core@7.29.7)(@babel/runtime@7.29.7)(expo-widgets@57.0.15)(expo@57.0.18)(react-refresh@0.14.2) tailwindcss: specifier: ^4.0.0 version: 4.3.0 @@ -528,11 +544,11 @@ importers: specifier: ^1.4.1 version: 1.5.0(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@clerk/electron': - specifier: 0.0.34 - version: 0.0.34(@clerk/electron-passkeys@0.0.3)(electron-store@8.2.0)(electron@41.5.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + specifier: 0.0.37 + version: 0.0.37(@clerk/electron-passkeys@0.0.3)(electron-store@8.2.0)(electron@43.4.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@clerk/react': - specifier: 6.14.4 - version: 6.14.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + specifier: 6.14.7 + version: 6.14.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@dnd-kit/core': specifier: ^6.3.1 version: 6.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -553,7 +569,7 @@ importers: version: 0.9.0 '@legendapp/list': specifier: 'catalog:' - version: 3.3.5(patch_hash=064530db83875fa671559a81ae42dc159726dc2dd6ec4c982da44ff7c7a74706)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + version: 3.3.5(patch_hash=03ec41339cd915ecb9a774a6b90cc2197c29038f7db67c4d2e55cd3971e5be43)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@lexical/react': specifier: ^0.41.0 version: 0.41.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(yjs@13.6.31) @@ -590,6 +606,9 @@ importers: effect: specifier: 4.0.0-beta.103 version: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) + heic-to: + specifier: ^1.5.2 + version: 1.5.2 jose: specifier: 'catalog:' version: 6.2.2 @@ -675,9 +694,6 @@ importers: compression: specifier: ^1.8.1 version: 1.8.1 - msw: - specifier: 2.12.11 - version: 2.12.11(@types/node@24.12.4)(typescript@6.0.3) tailwindcss: specifier: ^4.0.0 version: 4.3.0 @@ -713,10 +729,10 @@ importers: version: link:../../packages/shared alchemy: specifier: 2.0.0-beta.65 - version: 2.0.0-beta.65(75567d8add6e3362e26fe00f83a97bee) + version: 2.0.0-beta.65(00c448ade6580e73d10ccfe1b32cee97) drizzle-orm: specifier: 1.0.0-rc.4 - version: 1.0.0-rc.4(@cloudflare/workers-types@4.20260604.1)(@effect/sql-d1@4.0.0-beta.101(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)))(@effect/sql-pg@4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)))(@effect/sql-sqlite-bun@4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)))(@libsql/client@0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6))(bun-types@1.3.14)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(expo-sqlite@56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6))(mysql2@3.22.4(@types/node@24.12.4))(pg@8.21.0)(zod@4.4.3) + version: 1.0.0-rc.4(@cloudflare/workers-types@4.20260604.1)(@effect/sql-d1@4.0.0-beta.101(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)))(@effect/sql-pg@4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)))(@effect/sql-sqlite-bun@4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)))(@libsql/client@0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6))(bun-types@1.3.14)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(expo-sqlite@57.0.2(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6))(mysql2@3.22.4(@types/node@24.12.4))(pg@8.21.0)(zod@4.4.3) effect: specifier: 4.0.0-beta.103 version: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) @@ -773,10 +789,28 @@ importers: effect: specifier: 4.0.0-beta.103 version: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) + mdast-util-directive: + specifier: ^3.1.0 + version: 3.1.0 + micromark-extension-directive: + specifier: ^4.0.0 + version: 4.0.0 + micromark-util-character: + specifier: ^2.1.1 + version: 2.1.1 + remark-parse: + specifier: ^11.0.0 + version: 11.0.0 + unified: + specifier: ^11.0.5 + version: 11.0.5 devDependencies: '@effect/vitest': specifier: 4.0.0-beta.103 version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) + micromark-util-types: + specifier: ^2.0.2 + version: 2.0.2 vite-plus: specifier: 'catalog:' version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) @@ -899,9 +933,6 @@ importers: packages/tailscale: dependencies: - '@effect/platform-node': - specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@t3tools/shared': specifier: workspace:* version: link:../shared @@ -927,9 +958,9 @@ importers: '@electron/asar': specifier: ^3.4.1 version: 3.4.1 - '@t3tools/contracts': - specifier: workspace:* - version: link:../packages/contracts + '@electron/osx-sign': + specifier: 2.7.0 + version: 2.7.0 '@t3tools/shared': specifier: workspace:* version: link:../packages/shared @@ -942,9 +973,6 @@ importers: pngjs: specifier: 7.0.0 version: 7.0.0 - yaml: - specifier: ^2.9.0 - version: 2.9.0 devDependencies: '@effect/vitest': specifier: 4.0.0-beta.103 @@ -958,8 +986,15 @@ importers: packages: - '@adobe/css-tools@4.5.0': - resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==} + '@ai-sdk/provider-utils@4.0.49': + resolution: {integrity: sha512-8e7pd+82bobqrFOaD5dG/PiEuvLYr5olaE3I56ch0jipR0H7sGD6ohwTUynv6k8O8QidWiyIbEsZCtr/2dyXIA==} + engines: {node: '>=18.17'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/provider@3.0.15': + resolution: {integrity: sha512-XeZW1CcDF2GMbH4wejW6xBRI2QCOgnkVYUnxoeDadB1mf85riL2bMUeDoh+6gJ/r4mjNfzUPW8OjLjvwTP0u1Q==} + engines: {node: '>=18'} '@alcalzone/ansi-tokenize@0.2.5': resolution: {integrity: sha512-3NX/MpTdroi0aKz134A6RC2Gb2iXVECN4QaAXnvCIxxIm3C3AVB1mkUe8NaaiyvOpDfsrqWhYtj+Q6a62RrTsw==} @@ -1653,12 +1688,6 @@ packages: cpu: [x64] os: [win32] - '@callstack/liquid-glass@0.7.1': - resolution: {integrity: sha512-N2rzs8g3kneI5G/98AZdVtjc6OUFBBFwPxVGVMoXUEI7QiioB4+aWwbImg5h8Z3Vb/T+fgLYWmmNhi27wHwE4g==} - peerDependencies: - react: '*' - react-native: '*' - '@capsizecss/unpack@4.0.1': resolution: {integrity: sha512-CuNiSqg7+e1cO/GjffyMOm5Tt2jUF9CWHHnvQ/UkqvtkGfHdgwEC0wpmq7fkN3gxwpRnrAN0WzO3vREKmNolMQ==} engines: {node: '>=18'} @@ -1681,8 +1710,8 @@ packages: resolution: {integrity: sha512-WsphTvDFHDuQilKI7dyVE5qmt7USu8qSrujAq6SqpEHbVFfNBfINDNuzxlF9M2skOTfHFfgiTE8Jjb1egIBGLg==} engines: {node: '>=20.9.0'} - '@clerk/clerk-js@6.29.2': - resolution: {integrity: sha512-4i4RE+ZQ0hKDDFSRKCISQPBy07SfFeFAhBUhNj2j01Nx2n4pqV+5iyg2Vf27+NKFNkgq7kdmZGvtug24D++8WQ==} + '@clerk/clerk-js@6.30.1': + resolution: {integrity: sha512-ipsUhTf1uPJ5az4eiLOCM1Qz8z0I4kl54N6RtnL3VbxezwptPEUHx1PlHnHKUjTCV/PHNVq/3vht9w+8VLOoHw==} engines: {node: '>=20.9.0'} '@clerk/electron-passkeys-darwin-arm64@0.0.3': @@ -1709,8 +1738,8 @@ packages: resolution: {integrity: sha512-OHhIe88qDL+FxyBalXdXNHAS5eEramr6Rerp+6iNkfkjqT8rx4hHNmfpmjg5/T1/am8QfknbOBZkqoXZlCrjPg==} engines: {node: '>=20.9.0'} - '@clerk/electron@0.0.34': - resolution: {integrity: sha512-ZY6v8G1ArieIWvutMEt5HBJtpHN4ys4F4newNlOTElv//cpW29zaeZ65M8WuKttKr7fB+MH6wzzCoX/Ha+fiSg==} + '@clerk/electron@0.0.37': + resolution: {integrity: sha512-NsATM6rMISdL1K3mlKVPqZpDUA4h+uWmbGv8h9PK3zYfnwMgSpDKRX2y341Lt0fhz+eWkZpMU5SQmlZPNqwiww==} engines: {node: '>=20.9.0'} peerDependencies: '@clerk/electron-passkeys': 0.0.3 @@ -1735,7 +1764,7 @@ packages: expo: '>=54 <58' expo-apple-authentication: '>=7.0.0' expo-auth-session: '>=5' - expo-constants: '>=12' + expo-constants: 57.0.16 expo-crypto: '>=12' expo-local-authentication: '>=13.5.0' expo-secure-store: '>=12.4.0' @@ -1765,15 +1794,15 @@ packages: react-dom: optional: true - '@clerk/react@6.14.4': - resolution: {integrity: sha512-vMv3SU8dvo/b09/FAY7A0xaAe+YhZ2u4nvkRUUdqmOi8u3HNSJ3s2k2PIb0cNHsLurMvDzw/dD2Hbk97MPnN/A==} + '@clerk/react@6.14.7': + resolution: {integrity: sha512-+d+VqD4nZR3vBn5UU++H96zloHFDe+Ll0sGwMmLXnHtoIs9oMx91GaGXzXo9rU83kl660EfAmgeWcsLMfWnOYg==} engines: {node: '>=20.9.0'} peerDependencies: react: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0 react-dom: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0 - '@clerk/shared@4.29.2': - resolution: {integrity: sha512-9c9Mc1oqumsqo+JY5R37O1ipwcG3RmwPK9oadBLL9E4VxZXthXVaFI9u+1/I4BSFGA/B9BO+17tkVDL4G0Wpbg==} + '@clerk/shared@4.30.1': + resolution: {integrity: sha512-Mawatm7CTKZXBqIW8t/z9LfoAKgOHtRRxROpnJ4VIkTdgzj/mmAZhPOPjzUttPXXtulR1uLWisvQXEMNeF/jTQ==} engines: {node: '>=20.9.0'} peerDependencies: react: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0 @@ -2024,6 +2053,10 @@ packages: resolution: {integrity: sha512-XQsZgjm2EcVUiZQf11UBJQfmZeEmOW8DpI1gsFeln6w0ae0ii4dMQEQ0kjl6DspdWX1aGY1/loyXnP0JS06e/A==} engines: {node: '>=0.8.0'} + '@electron-internal/extract-zip@1.0.5': + resolution: {integrity: sha512-+bqFCP98pLI0Tt0XQo1TmlXtwjWchISndDOxCkEcIuUgXWpBnLyRI+2DU+mesvnMMX6L1XDqYNA0lXNDHd/yiA==} + engines: {node: '>=22.12.0'} + '@electron/asar@3.4.1': resolution: {integrity: sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA==} engines: {node: '>=10.12.0'} @@ -2033,14 +2066,14 @@ packages: resolution: {integrity: sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw==} hasBin: true - '@electron/get@2.0.3': - resolution: {integrity: sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==} - engines: {node: '>=12'} - '@electron/get@3.1.0': resolution: {integrity: sha512-F+nKc0xW+kVbBRhFzaMgPy3KwmuNTYX1fx6+FxxoSnNgwYX6LD7AKBTWkU0MQ6IBoe7dz069CNkR673sPAgkCQ==} engines: {node: '>=14'} + '@electron/get@5.1.0': + resolution: {integrity: sha512-3kSBtG8ObcTVfXanm5vVJ6UnBLEVmVsRk1M+vGqCuMBV+XLCbJYuWQful+yIy0GQDsSlK0kHEriEHn7SPk4EnA==} + engines: {node: '>=22.12.0'} + '@electron/notarize@2.5.0': resolution: {integrity: sha512-jNT8nwH1f9X5GEITXaQ8IF/KdskvIkOFfB2CvwumsveVidzpSc+mvhhTMdAGSYF3O+Nq49lJ7y+ssODRXu06+A==} engines: {node: '>= 10.0.0'} @@ -2050,6 +2083,11 @@ packages: engines: {node: '>=12.0.0'} hasBin: true + '@electron/osx-sign@2.7.0': + resolution: {integrity: sha512-9DGhNqKMl6ibkhUoXbN7OHX2gZznfY10L3ZwG0u6r667Kfb6kec4JEfFTXftoqzmOfZ+OzwDbr4p/nKBMHnz0g==} + engines: {node: '>=22.12.0'} + hasBin: true + '@electron/rebuild@4.0.4': resolution: {integrity: sha512-Rzc39XPdk/+/wBG8MfwAHohXflep0ITUfulb6Rgz3R0NeSB1noE+E9/M/cb8ftCAiyDD9PPhLuuWgE1GaInbKg==} engines: {node: '>=22.12.0'} @@ -2427,12 +2465,12 @@ packages: '@expo-google-fonts/material-symbols@0.4.38': resolution: {integrity: sha512-IJkBtN1o8u9BW5fvSii1MyHPQ7Q0HxbWcVBvOrOzgMLpVtZw7R2w94wBTVR7kZwv3w1JNTESMmLA5Sqn1+Z36A==} - '@expo/cli@56.1.16': - resolution: {integrity: sha512-VBQn0mqAwc67b9Cn0RVXyeodghomAx5xGRhA/bXaQzuxDjMQk0zIOb6pXMZX7yiIwJW66UZt/zQiJNSv6aWJYw==} + '@expo/cli@57.0.20': + resolution: {integrity: sha512-ZjWz7SA5TBTyKq4+aFRUPgMFxmqHtKkDMv6d85J2UAAjdRhkNRf+B80t+rr5IFo2ywuTvyrPRBth4ei4w08olA==} hasBin: true peerDependencies: expo: '*' - expo-router: '*' + expo-router: 57.0.17 react-native: '*' peerDependenciesMeta: expo-router: @@ -2443,20 +2481,20 @@ packages: '@expo/code-signing-certificates@0.0.6': resolution: {integrity: sha512-iNe0puxwBNEcuua9gmTGzq+SuMDa0iATai1FlFTMHJ/vUmKvN/V//drXoLJkVb5i5H3iE/n/qIJxyoBnXouD0w==} - '@expo/config-plugins@56.0.9': - resolution: {integrity: sha512-/6a/S9USwx8OC9tGjHxbviLFiBHyueN3aoNWMLvWDEJoZ1CIVW800ZBzwXq/FYNK2qzcN1LxFmQtzD1zeFQKNA==} + '@expo/config-plugins@57.0.9': + resolution: {integrity: sha512-hHgfL1avkCdEvDSw7IwlKwRYYNgcxzbNNMIk6W6lTkJpY0MajinAfeJUS0J+wPCsjUfGbVqOJM+XhPaO5ulUxg==} - '@expo/config-types@56.0.6': - resolution: {integrity: sha512-4Y6Aum5J4Re5NnxGVofRNe1aDwUBOmWhQYkynZsqzRtX/zEA1ADUeyHXuEckv9YD9djiyT7bKtLt5gKL3mA6VQ==} + '@expo/config-types@57.0.2': + resolution: {integrity: sha512-ewW08OonrcRIsRKIlFvvcmmafE5zemb1ocu3HkNwtVPyRtj2w42pZCAkMIROYpcVBaPnc3mDT9UZDzwXWC3i6g==} - '@expo/config@56.0.9': - resolution: {integrity: sha512-/lqFeWGSrhpKJVP8tTN8LjuoIe8u8q2w7FzBL0C+wHgl+WM8l1qUIEYWy/sMvsG/NbpUIUsDHJRhQvOkU58eIw==} + '@expo/config@57.0.9': + resolution: {integrity: sha512-dmzlKraIFxa7wLwV6K7WzI8jp6QZpW6Mc5mGjLimJUFjzh4uQdYaT3m3plEutM5yxBoBEwqzks7l+I/ljCbxAQ==} '@expo/devcert@1.2.1': resolution: {integrity: sha512-qC4eaxmKMTmJC2ahwyui6ud8f3W60Ss7pMkpBq40Hu3zyiAaugPXnZ24145U7K36qO9UHdZUVxsCvIpz2RYYCA==} - '@expo/devtools@56.0.2': - resolution: {integrity: sha512-ANl4kPdbe0/HQYWkDEN79S6bQhI+i/ZCnPxuC853pPsB4svhINC7Ku9lmGOKPsUUWWnrHg1spkDGQBZ4sD6JxQ==} + '@expo/devtools@57.0.1': + resolution: {integrity: sha512-GyUf+wFNkbttaX0jR7MZa9bm77U0IrLg6d2AjpxdyoXw/w4abHoXG0oFufwLMgP9zLTd5+Ct4X/ffNUTnlzZgg==} peerDependencies: react: '*' react-native: '*' @@ -2466,62 +2504,62 @@ packages: react-native: optional: true - '@expo/dom-webview@56.0.5': - resolution: {integrity: sha512-UIEJxkLg6cHqofKrpWpkn9E6ApxVRtCgZhZkARPr9VV7rBVloJgeroTHs31YgU/JpbI5lLQOnfOlGo54W6C2Ew==} + '@expo/dom-webview@57.0.1': + resolution: {integrity: sha512-lAKsME4SAq+8sf56oN0DX5TBYyruupoRxbWbD2xf9RnKY8y6x8eb9LCE5pxSN0qyWdqnp+0wmyWzDkKboThKAw==} peerDependencies: expo: '*' react: '*' react-native: '*' - '@expo/env@2.3.0': - resolution: {integrity: sha512-9HnnIbzwTTdbwSjNLXTk0fPm9ZwMJ7c1/31tsni8HZ8Q62KzYCyspahH+V365vg5J6lr001DzNwBxVWSaYCQLg==} + '@expo/env@2.4.3': + resolution: {integrity: sha512-M1NXeZCA1mkMkYOyIe7PlyRX0/jqFtMoJgyblnlq/vpCRfmueFT7RnGSQG8uEFDF5WHOFGijAQ3fogPh3/n5Ng==} engines: {node: '>=20.12.0'} - '@expo/expo-modules-macros-plugin@0.2.2': - resolution: {integrity: sha512-4IMzPDIo/VOXREQjsJtliSfqYVZvfzU2SLFS/9sKMWF848S8CHx+e/E+Vf0TcMvpWCCKX5umyqxb13KJJ+YUzg==} + '@expo/expo-modules-macros-plugin@0.6.1': + resolution: {integrity: sha512-cpsLZE4rqkc1Y3eZTkxB98jrqY1YXgetmtxFt8q89jBRmk3quRuk1BZo+VcnCSObZardjg99r1k5xijEMONFGA==} - '@expo/fingerprint@0.19.4': - resolution: {integrity: sha512-PsowRlO8+S7JlO8go7yhNEXp7sqlsWDE2AlCwoss7zH0dcajXFo74Fy0KdXEc4UXK7kKoHD37oDgsZ8aHSLr7A==} + '@expo/fingerprint@0.20.11': + resolution: {integrity: sha512-GC43EcjQwzpzNZISqmhvjKdi+6FTfxnC9ZxOeg6osCkYXJz94j107l0QpPLipg62khD5HAA5G5cUhevffbRC4w==} hasBin: true - '@expo/image-utils@0.10.1': - resolution: {integrity: sha512-YDeefvmYdihS7Wp3ESDUVnOgOSWmj2Cczm9lVNDdm4MqQLdAKm/LPYg83HtFQPfefRlAxyHrQR/O9kIXN9C1Wg==} + '@expo/image-utils@0.11.5': + resolution: {integrity: sha512-KPQBTpmpAfy/Vu9y4wPW808/qtZxjYmyJg8cm2QCPAupp+qEWA3b5zmk0ulOwQ9OgeHxuCPgUqWgkwHFo7UsrQ==} '@expo/image-utils@0.8.14': resolution: {integrity: sha512-5Sn+jG4Cw+shC2wDMXoqSAJnvERbiwzHn05FpWtD5IBflfTIs5gUmjzwiGVyjOdlMSQhgRrw/AymPbmO9h9mpQ==} - '@expo/inline-modules@0.0.12': - resolution: {integrity: sha512-SNIZr/HWfIQPTZBwmukItxpc7ws1SgMUywYq1dnQvDknQDjJcuWAasIRFUjsK15yQ1xb4G5CP7VHtbN3V4lENg==} + '@expo/inline-modules@0.1.7': + resolution: {integrity: sha512-Bz/khd1gIJqDkje7t5ejD5e9jFbm4xEJzWSwRKadRo6gruepbw1xJ3Eb+e58OS8fY1ZNQYjzZbspnuph67sN0g==} - '@expo/json-file@10.2.0': - resolution: {integrity: sha512-S6XzKe3R9GQeHiUPXc3xJjOv2VJhOEwFYf7xdC2z2cUqt3kZJ9mSO877sNQloVdnW/SUCtPY3bexlM7nwq+CAQ==} + '@expo/json-file@11.0.1': + resolution: {integrity: sha512-zxHWj4MKKMAL29ZQSY/Fssx4Thluk40JmuGNaeS078wy/NhlFhnVi+rHHunulE3xJAJ0CM73m8VK2+GkF9eRwQ==} - '@expo/local-build-cache-provider@56.0.8': - resolution: {integrity: sha512-UsuXwpNi57MNhzZ3be4XThc8xW6nzk3Wu37s1+2qcfZGeJcMLKDFfwO6n8YXeIiGlCsOi0Ee1rsTdgjrKt/YJQ==} + '@expo/local-build-cache-provider@57.0.8': + resolution: {integrity: sha512-SEdE0pAQrr90bRh3MNR0ZuwoIBw390cYdFgbn7Vk0Mtm9EHaBfP7kYj+2hXnXZJgOEm3/JMtfoD3rV7rWA9FGg==} - '@expo/log-box@56.0.13': - resolution: {integrity: sha512-QWRZSpWPyjkDLVQio4R7oAzg/Av2MOt/DciFkfjr8qQ3qxGVn1Rt1oHP/80hvcWDcHFV7N6PqpyxRXw6nbxzKQ==} + '@expo/log-box@57.0.4': + resolution: {integrity: sha512-IxwS9s1L2muj8mj8AQSuiy7u8OFJdc02NRFo2me/Tj6DiaeG5SREqmpBE4rQpR2cadqSg5jl8Qab8Cjie616dg==} peerDependencies: - '@expo/dom-webview': ^56.0.5 + '@expo/dom-webview': 57.0.1 expo: '*' react: '*' react-native: '*' - '@expo/metro-config@56.0.14': - resolution: {integrity: sha512-O3CIHruaTJhswPAf/nf3i8QQ3f2jl+mEwSea1eb3khuplabdy/wTQz+JvHN8VGUFyg7JKwUGU1QfO6T3JiSQqA==} + '@expo/metro-config@57.0.12': + resolution: {integrity: sha512-S62Lrq35HZqBFD55423pmWb8PjaiR/W02zQC1uECBmw1vTN8WZaFz4TJ0i21EeJzwfebMb9MLxL8JZ102Z6VbA==} peerDependencies: expo: '*' peerDependenciesMeta: expo: optional: true - '@expo/metro-file-map@56.0.3': - resolution: {integrity: sha512-5OGW3z8LgEYgMJOR7F3pC8llFLkb1fVqwAewbCl6S4Vkha8AFQMwOjT+9Wbka+V4rmpljpGqOnMhF4xZbD961w==} + '@expo/metro-file-map@57.0.2': + resolution: {integrity: sha512-tb50nSIWwKpRufSkGuivOK0FbUxv1Uwqptb0SzFTk0bkmYmcy3gIwnCbOHTfmrQDVndRhPEiu2SYgh9Z0PCvGg==} - '@expo/metro-runtime@56.0.15': - resolution: {integrity: sha512-WIWeVsL6kCSB57oYZdUA4MTkH7c67UFMIjdNoQzKXwxZYwBFE/xL2cGPDC3z8RWt0femzJTVxAVZUOW/hiqRzA==} + '@expo/metro-runtime@57.0.14': + resolution: {integrity: sha512-LVpsfF0QOzlLSKlHYpxgW1cP1NKcYeaoalLO18jYHgqseSveEkb9VOffFpicTQ7AN03odZ4fgkUF+p5YpoY4yw==} peerDependencies: - '@expo/log-box': ^56.0.13 + '@expo/log-box': ^57.0.4 expo: '*' react: '*' react-dom: '*' @@ -2530,21 +2568,21 @@ packages: react-dom: optional: true - '@expo/metro@56.0.0': - resolution: {integrity: sha512-5gIgQHtEpjjvsjKfVtIv23a98LLRV0/y07PDShEwYSytAMlE3FSF8RHXqtHc1sUJL6dn7hnuIBpIbrLXXuVi0A==} + '@expo/metro@56.0.2': + resolution: {integrity: sha512-Ld5AeYMCCDa8bLeWhfuLbZFFjlV3f6ORqyPz2glGh6RltIngMuLf9BTC2yvHFjkKuGxL5SynijmA8xmNNWn5iA==} - '@expo/osascript@2.6.0': - resolution: {integrity: sha512-QvqDBlJXa8CS2vRORJ4wEflY1m0vVI07uSJdIRgBrLxRPBcsrXxrtU7+wXRXMqfq9zLwNP9XbvRsXF2omoDylg==} + '@expo/osascript@2.7.1': + resolution: {integrity: sha512-Zn03EX6In7ts2lPUW2ESUSkEhEWQN1qqsiXjadtZMJOuZRkMiAg1ZQHuvz9DjByDWNJ2pBwAGyrts9lj9k389g==} engines: {node: '>=12'} - '@expo/package-manager@1.12.1': - resolution: {integrity: sha512-fQLiFAcFRWF53mtuLK32SUJQ1ahhrTcBZPZPedYTiUT5ha5FF+UO6bPtCc0Y/hgj0/m3HCGBAuSHjbg2kI9oPQ==} + '@expo/package-manager@1.13.1': + resolution: {integrity: sha512-y/K+CaYYpZpNGZhSX4HyLT/vyIunFjNfyoxNysPBCefeLKI/VCx6f9LNPzrxayr3rCYO5bl9O8H+HRQK265Nkg==} - '@expo/plist@0.7.0': - resolution: {integrity: sha512-vrpryU1GoqSIRNqRB2D3IjXDmzNYfiQpEF6AH/xknlD7eiYmEDt3mb26V7cLcedcPG8PY/1xWHdBXVQJfEAh6Q==} + '@expo/plist@0.8.1': + resolution: {integrity: sha512-3gTReGIUm0oRaMClsAJYxBnVPCl6fVpsl8HS+DTVxDhW4GyVyxg9E/Znm3BvcHtUJ51RJJI14pC1wvrNilCRHw==} - '@expo/prebuild-config@56.0.16': - resolution: {integrity: sha512-ce9ENfPWO4WUWUVQz0OaqL3KYZ7YofP8O35ncnn7CHCaKwQ7BqxcCGJbh+qvP1UjlWeNB3CjHPrXXJ3bnZwlJw==} + '@expo/prebuild-config@57.0.15': + resolution: {integrity: sha512-xTbWHroj0PDmlbqvmU+zF9ZZxveJkiuyiPoeRJYRGruFHebRAWnoTdw5S7d/UCzDBI8ropGGu9g2eb2nMxtvAw==} '@expo/require-utils@55.0.5': resolution: {integrity: sha512-U4K/CQ2VpXuwfNGsN+daKmYOt15hCP8v/pXaYH6eut7kdYZo6SfJ1yr67BIcJ+1Gzzs+QzTxswAZChKpXmceyw==} @@ -2554,23 +2592,23 @@ packages: typescript: optional: true - '@expo/require-utils@56.1.3': - resolution: {integrity: sha512-KyLeOn/zzQSvuPpV5YhB/FPKnpQytno4luN918bGdPDssLBoS3N/0UbC3W0rJAn9kSFu+XpfR81eABRVsSdfgQ==} + '@expo/require-utils@57.0.5': + resolution: {integrity: sha512-kTAXj9lDFEIPMsbAOGCGbjBbMF0oi7CqkYM79KOX0DDD9wSwXmlKL1z2h8OwsrBf7mbOo2DjlRvZu4BEjrIxGw==} peerDependencies: - typescript: ^5.0.0 || ^5.0.0-0 || ^6.0.0 + typescript: ^5.0.0 || ^5.0.0-0 || ^6.0.0 || ^7.0.0 peerDependenciesMeta: typescript: optional: true - '@expo/router-server@56.0.14': - resolution: {integrity: sha512-2UCTtZfcq1ZPgp3wk8/+sq9DvFI9UxrPr1jcEKMAF2DGAJLosnpc8GWNNg2hkjt6SHUOdFHIPxujWPYyho2y3A==} + '@expo/router-server@57.0.8': + resolution: {integrity: sha512-lEPGYoDmh97yyEcY/MFWQsfE7yIgMRnGDcnsBN7B6AiWBFSJIMEcGnO0aYPgY0ZjNWyJDi7Ee8QZRGqB+LhI6w==} peerDependencies: - '@expo/metro-runtime': ^56.0.15 + '@expo/metro-runtime': ^57.0.14 expo: '*' - expo-constants: ^56.0.18 - expo-font: ^56.0.6 - expo-router: '*' - expo-server: ^56.0.5 + expo-constants: 57.0.16 + expo-font: ^57.0.1 + expo-router: 57.0.17 + expo-server: ^57.0.3 react: '*' react-dom: '*' react-server-dom-webpack: ~19.0.1 || ~19.1.2 || ~19.2.1 @@ -2584,8 +2622,8 @@ packages: react-server-dom-webpack: optional: true - '@expo/schema-utils@56.0.1': - resolution: {integrity: sha512-CZ/+mYbQmWeOnkCGlWy9K+lFxbJSMFY7+TqBZcKzBSTU5Q7IGRvn/sOG3TdNjIdLPmbA8xe7R/c3UUQ28R9i9w==} + '@expo/schema-utils@57.0.2': + resolution: {integrity: sha512-fMu/jyN0l1Wzv7XkeWR4IYCx1M8ryui3FdBNGrWwbRgJ7EhxXxK8E2jxP2W3pbgUwUY0V3hG8+GyfCZwny+Lxw==} '@expo/sdk-runtime-versions@1.0.0': resolution: {integrity: sha512-Doz2bfiPndXYFPMRwPyGa1k5QaKDVpY806UJj570epIiMzWaYyCtobasyfC++qfIXVb5Ocy7r3tP9d62hAQ7IQ==} @@ -2597,23 +2635,20 @@ packages: '@expo/sudo-prompt@9.3.2': resolution: {integrity: sha512-HHQigo3rQWKMDzYDLkubN5WQOYXJJE2eNqIQC2axC2iO3mHdwnIR7FgZVvHWtBwAdzBgAP0ECp8KqS8TiMKvgw==} - '@expo/ui@56.0.18': - resolution: {integrity: sha512-2XgH5obigGtXm8zlb/V3g87NSiIcBcJ1xoQOEQYPoExL1DCNsHzaIecTh1XG/f/45ardo4OZNJwpbfYJ9X3qrQ==} + '@expo/ui@57.0.14': + resolution: {integrity: sha512-m8Z5dCplkOOQ70lu/xfB67qhk00Muw4U1T9frHBIW3LxxJeJZQ+jOmV6vCk6O6zVgoG/ib1et7PeX6etxI4Rsw==} peerDependencies: '@babel/core': '*' expo: '*' react: '*' react-dom: '*' react-native: '*' - react-native-reanimated: '*' react-native-worklets: '*' peerDependenciesMeta: '@babel/core': optional: true react-dom: optional: true - react-native-reanimated: - optional: true react-native-worklets: optional: true @@ -3605,19 +3640,6 @@ packages: '@radix-ui/primitive@1.1.3': resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} - '@radix-ui/react-collection@1.1.7': - resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - '@radix-ui/react-compose-refs@1.1.2': resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==} peerDependencies: @@ -3649,15 +3671,6 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-direction@1.1.1': - resolution: {integrity: sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@radix-ui/react-dismissable-layer@1.1.11': resolution: {integrity: sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==} peerDependencies: @@ -3741,19 +3754,6 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-roving-focus@1.1.11': - resolution: {integrity: sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - '@radix-ui/react-slot@1.2.3': resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==} peerDependencies: @@ -3763,28 +3763,6 @@ packages: '@types/react': optional: true - '@radix-ui/react-slot@1.2.4': - resolution: {integrity: sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-tabs@1.1.13': - resolution: {integrity: sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - '@radix-ui/react-use-callback-ref@1.1.1': resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==} peerDependencies: @@ -3834,6 +3812,11 @@ packages: resolution: {integrity: sha512-gMDYY2rw6OWajCcDlXSIgs2LC432YJXSb3Lm5yM187uhRgBYddoEVULi36h+IolX3r7jSb3ew7vn9FfI8NSo0A==} hasBin: true + '@react-native-ai/apple@0.12.0': + resolution: {integrity: sha512-BC/kEDbCZprv1xcQCWsgapXm/WEsj5lieBDvU1vJXStrN+BG+hWM01zoqDGTD/j71zvt9DEfHcMvEqbrGY2uAA==} + peerDependencies: + react-native: '>=0.76.0' + '@react-native-masked-view/masked-view@0.3.2': resolution: {integrity: sha512-XwuQoW7/GEgWRMovOQtX3A4PrXhyaZm0lVUiY8qJDvdngjLms9Cpdck6SmGAUNqQwcj2EadHC1HwL0bEyoa/SQ==} peerDependencies: @@ -3846,78 +3829,78 @@ packages: react: '*' react-native: '*' - '@react-native/assets-registry@0.85.3': - resolution: {integrity: sha512-u9ZiYP23vA2IFtdFQFmetzSmk6SM0xgKIoiOsr1hXNHjHaLhOm+/Ph1ud57wX6+Dbwdzx8coJgnzSKL3W21PCg==} + '@react-native/assets-registry@0.86.3': + resolution: {integrity: sha512-TDhgCZA4wjJg84d5A9swiOQYPIWSKEEVdg9IwMFZDupQzW/F3QoLUrfAJOcalgqTDA9/buTB8awhE3Whwg6u9Q==} engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} - '@react-native/babel-plugin-codegen@0.85.3': - resolution: {integrity: sha512-Wc94zGfeFG8Njf9SHMPfYZP04kjigkOps6F1TYTvd7ZVXuGxqseCDgxc50LWcOhOCLypI9n3oVVqz81C3p44ZA==} + '@react-native/babel-plugin-codegen@0.86.3': + resolution: {integrity: sha512-O6Xza4JBGPIU8J7YbKTyBoYL4thpy8jMW/oaLDWdAyOwYHKIjK47pAL5HUEbOe2bWz2PEKjbYRF2ApkJv1ottQ==} engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} - '@react-native/babel-preset@0.85.3': - resolution: {integrity: sha512-fD7fxEhkJB/aF57tWoXjaAWpklfrExYZS3k6aXPP3BQ77DZY7gvf/b7dbirwjID6NVnP1JDRJyTuPBGr0K/vlw==} + '@react-native/babel-preset@0.86.3': + resolution: {integrity: sha512-/eqs/Hy9RZRcjdcs4wj3Cqmxvtb3NM5g+Uuh1RIvsjynMO8PRsrVWWLWgBcZL/jYUo+ogXd2NFB0W8L5Bg89xw==} engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} peerDependencies: '@babel/core': '*' - '@react-native/codegen@0.85.3': - resolution: {integrity: sha512-/JkS1lGLyzBWP1FbgDwaqEf7qShIC6pUC1M0a/YMAd/v4iqR24MRkQWe7jkYvcBQ2LpEhs5NGE9InhxSv21zCA==} + '@react-native/codegen@0.86.3': + resolution: {integrity: sha512-Ux4jHi0fh+bdtVEcL0gaPLbY56V+SvFUDl/8sRAE1jdb4k+o7fT/4Nc29yz4X+qfjstkSqObQTMBGhdzxH9JvA==} engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} peerDependencies: '@babel/core': '*' - '@react-native/community-cli-plugin@0.85.3': - resolution: {integrity: sha512-fs85dmbIqNmtzEixDb0g+q6R3Vt4H9eAt8/inIZdDKfjN76+sUJA2r1nxODQ76bU23MrIbz8sI7KFBPaWk/zQw==} + '@react-native/community-cli-plugin@0.86.3': + resolution: {integrity: sha512-qSDL9LQc5mZSZPNczT95WU9YQuPzxBklgON9vLhhqfI0yWIwKInqFx88dQ/uiEXBtf0yossthaQIqA4Ml6bF6g==} engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} peerDependencies: '@react-native-community/cli': '*' - '@react-native/metro-config': 0.85.3 + '@react-native/metro-config': 0.86.3 peerDependenciesMeta: '@react-native-community/cli': optional: true '@react-native/metro-config': optional: true - '@react-native/debugger-frontend@0.85.3': - resolution: {integrity: sha512-uAu7rM5o/Np1zgp6fi5zM1sP1aB8DcS7DdOLcj/TkSutOAjkMqqd2lWt1/+3S7qXexRHVK5XcP+o3VXo4L/V0A==} + '@react-native/debugger-frontend@0.86.3': + resolution: {integrity: sha512-TQmeofQ0PcuylhhlleOeuzHYZfbrgm3gayXzowqUEzgRisTm1D40/J3ggqs7XkQi5HP5ZA3n8dHmKL9vIzPcsw==} engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} - '@react-native/debugger-shell@0.85.3': - resolution: {integrity: sha512-/jRAaT9boiCttIcEwS02WPwYkUihqsjSaK/TMtHz05vT6uMgac9PaQt5kzBQLIABv5aEIa5gtrMmKVz49MjkjQ==} + '@react-native/debugger-shell@0.86.3': + resolution: {integrity: sha512-O4ds+J7xZfxkbih9T+cAGegBdvKSPKYJm/lDgC9CpEjFMkmzWTpVLU3Qsv9sqZuo58z+sGhIcJfPsCFFWHpqbQ==} engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} - '@react-native/dev-middleware@0.85.3': - resolution: {integrity: sha512-JYzBiT4A8w+KQt+dOD5v+ti+tDrGoPnsSTuApq3Ls4RB5sfWbDlYMyz3dbc8qBIHz9tv0sQ5+eOu6Xwqzr5AQA==} + '@react-native/dev-middleware@0.86.3': + resolution: {integrity: sha512-LiEPTqTg/63bYUnrPyHLfjTDCNhA/+CUqI1+DsA9tYyewtSbULd5awsva6SgE10I+2iMhgKXS3ymkhU/kSCrGA==} engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} - '@react-native/gradle-plugin@0.85.3': - resolution: {integrity: sha512-39dY2j50Q1pntejzwt3XL7vwXtrj8jcIfHq6E+gyu3jzYxZJVvMkMutQ39vSg6zinIQOX36oQDhidXUbCXzgoA==} + '@react-native/gradle-plugin@0.86.3': + resolution: {integrity: sha512-lxmx0GqLEWRIpZfpYFXlYVIs3ENQwaW6Vmp6oi29l2GoQJ1wZfFZRdMimDWlGEk8LKfHar3QH3iaPMkTcK9lEQ==} engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} - '@react-native/js-polyfills@0.85.3': - resolution: {integrity: sha512-U2+aMshIXf1uFn77tpBb/xhHWB9vkVrMpt7kkucAugF8hJKYTDGB587X7WwelHduK2KBfhl4giSv0rzZGoef9A==} + '@react-native/js-polyfills@0.86.3': + resolution: {integrity: sha512-eYIJ0es967+tePBFQDnl/gidVFxLns3fnbiK6rxscQrGodvuUO6hwxpQnfNynJ8MjbbndImXihXjcnJdc7SzJg==} engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} - '@react-native/metro-babel-transformer@0.85.3': - resolution: {integrity: sha512-omuKq+r7jM4XvCMIlNMPP7Up3SyB8o5EAdZtF7YXniKyq7UOMBqhYHFqgsdOXr0lT+3ADf7VCJG3sb82jlBrrQ==} + '@react-native/metro-babel-transformer@0.86.3': + resolution: {integrity: sha512-0nlwVkG0uT9o72nrGTKUz/IqDnCkM4zE0ihe4r+OJcxH93yBdDm58AXJ3EXZeSjv3osHEFXU7ceajdZiS34mTw==} engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} peerDependencies: '@babel/core': '*' - '@react-native/metro-config@0.85.3': - resolution: {integrity: sha512-sVo6HepUmCcpdfozEf91lA0FjpLNNZYu/Zi9FiYiAQTK8pzATXDVTqhvdxpFrQn435p5eUTSbllvbH/KN+bnyA==} + '@react-native/metro-config@0.86.3': + resolution: {integrity: sha512-qdzDMepV2xdUhsO4XC7idnnbt8K6+Hd59AjeK9y4KS86fxSQq5oL0TAbqUuajDGR7beNQoYTuS6AowN/zP0oaQ==} engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} - '@react-native/normalize-colors@0.85.3': - resolution: {integrity: sha512-hj0PScZEhIbcOvQV5yMKX3ha4XEIOy/SVE1Rrpp0beW0dpNLOgSC7KDxGewmDnIHK9YdQUXGY9eMEfShUMIaZw==} + '@react-native/normalize-colors@0.86.3': + resolution: {integrity: sha512-Cv3CDkprb67GrzuaS9BGbBJC/6G4lIw3nyKOHRKTqTTum4bn37y5+R0Z04L8mcbQN85eEohNrRwb7IOM4j6uvg==} - '@react-native/virtualized-lists@0.85.3': - resolution: {integrity: sha512-dsCjI//OIPEUJMyNHp4l7zNLVjCx7bcaRUceOCkU+IB17hkbtbGWvi7HjGFSzy7FJGmS/MOlcfpb72xXiy1Oig==} + '@react-native/virtualized-lists@0.86.3': + resolution: {integrity: sha512-1j44NEyNn05Ut40vHAmoSWbsIcybFkMAOBTwQt1PrESyfSS+qBoyU1LGIogNva0VIa0rQyEC5PzbA7R4/7Nhyw==} engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} peerDependencies: '@types/react': ^19.2.0 react: '*' - react-native: 0.85.3 + react-native: 0.86.3 peerDependenciesMeta: '@types/react': optional: true @@ -4435,6 +4418,8 @@ packages: '@t3tools/mobile-markdown-text@file:apps/mobile/modules/t3-markdown-text': resolution: {directory: apps/mobile/modules/t3-markdown-text, type: directory} peerDependencies: + '@t3tools/client-runtime': '*' + '@t3tools/shared': '*' expo-asset: '*' expo-clipboard: '*' expo-haptics: '*' @@ -4458,17 +4443,11 @@ packages: '@tabler/icons@3.44.0': resolution: {integrity: sha512-Wn0AOZG9sg0L+bjfMqq4eNhC6pQjIrk94LvvWYNYkY8KH8wC3YILRzQlrnVJc4FUeMxH/AK97QsYCX35H3LndA==} - '@tailwindcss/node@4.2.1': - resolution: {integrity: sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==} - '@tailwindcss/node@4.3.0': resolution: {integrity: sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==} - '@tailwindcss/oxide-android-arm64@4.2.1': - resolution: {integrity: sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg==} - engines: {node: '>= 20'} - cpu: [arm64] - os: [android] + '@tailwindcss/node@4.3.2': + resolution: {integrity: sha512-yWP/sqEcBLaD8JuA6zNwxoYKr75qxTioYwlRwekj5Jr/I5GXnoJfjetH/psLUIv74cYTH2lBUEzBkinthoYcBg==} '@tailwindcss/oxide-android-arm64@4.3.0': resolution: {integrity: sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng==} @@ -4476,11 +4455,11 @@ packages: cpu: [arm64] os: [android] - '@tailwindcss/oxide-darwin-arm64@4.2.1': - resolution: {integrity: sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw==} + '@tailwindcss/oxide-android-arm64@4.3.2': + resolution: {integrity: sha512-WHxqIuHpvZ5VtdX6GTl1Ik/Vp2YuN42Et+0CdeaVd/frQ9jAvGmvR8vLT+jk3e8/Q3x8kECB9+R17pgpp2BulA==} engines: {node: '>= 20'} cpu: [arm64] - os: [darwin] + os: [android] '@tailwindcss/oxide-darwin-arm64@4.3.0': resolution: {integrity: sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ==} @@ -4488,10 +4467,10 @@ packages: cpu: [arm64] os: [darwin] - '@tailwindcss/oxide-darwin-x64@4.2.1': - resolution: {integrity: sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw==} + '@tailwindcss/oxide-darwin-arm64@4.3.2': + resolution: {integrity: sha512-GZypeUY/IDJW3877KeM+O67vbXr3MBnbtEL4aYhNErv/JWZhye2vGSWWG9tB6iiqR2MqRNkY8IOUy4NdSZV26w==} engines: {node: '>= 20'} - cpu: [x64] + cpu: [arm64] os: [darwin] '@tailwindcss/oxide-darwin-x64@4.3.0': @@ -4500,11 +4479,11 @@ packages: cpu: [x64] os: [darwin] - '@tailwindcss/oxide-freebsd-x64@4.2.1': - resolution: {integrity: sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA==} + '@tailwindcss/oxide-darwin-x64@4.3.2': + resolution: {integrity: sha512-UIIzmefR6KO1sDU7MzRqAxC8iBpft/VhkGjTjnhoS6k7Z3rQ9wEgA1ODSiyH/tcSYssulNm4Ci3hOeK1jH7ccQ==} engines: {node: '>= 20'} cpu: [x64] - os: [freebsd] + os: [darwin] '@tailwindcss/oxide-freebsd-x64@4.3.0': resolution: {integrity: sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ==} @@ -4512,11 +4491,11 @@ packages: cpu: [x64] os: [freebsd] - '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.1': - resolution: {integrity: sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw==} + '@tailwindcss/oxide-freebsd-x64@4.3.2': + resolution: {integrity: sha512-GN+uAmcI6DNspnCDwtOAZrTz6oukJnp337qZvxqCGLd3BHBzJpO0ZbTLRvJNdztOeAmTzewewGIMPb0tk2R4WA==} engines: {node: '>= 20'} - cpu: [arm] - os: [linux] + cpu: [x64] + os: [freebsd] '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.0': resolution: {integrity: sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA==} @@ -4524,12 +4503,11 @@ packages: cpu: [arm] os: [linux] - '@tailwindcss/oxide-linux-arm64-gnu@4.2.1': - resolution: {integrity: sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ==} + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.2': + resolution: {integrity: sha512-4ABn7qSbdHRwTiDiuWNegCyb5+2FJ4vKIKc3DmKrvAFw7MU1Lm11dIkTPwUaFdTzc7IsOpDbqBrlh0x6y36U/w==} engines: {node: '>= 20'} - cpu: [arm64] + cpu: [arm] os: [linux] - libc: [glibc] '@tailwindcss/oxide-linux-arm64-gnu@4.3.0': resolution: {integrity: sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==} @@ -4538,12 +4516,12 @@ packages: os: [linux] libc: [glibc] - '@tailwindcss/oxide-linux-arm64-musl@4.2.1': - resolution: {integrity: sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==} + '@tailwindcss/oxide-linux-arm64-gnu@4.3.2': + resolution: {integrity: sha512-wDgEIGwoM8w8pufh9LVt1PahDgNdKXrLC2qfAnV3vAmococ9RWbxeAw4pxPttd/TsJfwjyLf90Dg1y9y8I6Emw==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] - libc: [musl] + libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.3.0': resolution: {integrity: sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==} @@ -4552,12 +4530,12 @@ packages: os: [linux] libc: [musl] - '@tailwindcss/oxide-linux-x64-gnu@4.2.1': - resolution: {integrity: sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==} + '@tailwindcss/oxide-linux-arm64-musl@4.3.2': + resolution: {integrity: sha512-J5Nuk0uZQIiMTJj3LEx4sAA9tMFUoXQZFv1J6An+QGYe53HKRJuFDi0rpq/tuouCZeAbOBY3kQ6g8qeD4TUjtA==} engines: {node: '>= 20'} - cpu: [x64] + cpu: [arm64] os: [linux] - libc: [glibc] + libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.3.0': resolution: {integrity: sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==} @@ -4566,12 +4544,12 @@ packages: os: [linux] libc: [glibc] - '@tailwindcss/oxide-linux-x64-musl@4.2.1': - resolution: {integrity: sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==} + '@tailwindcss/oxide-linux-x64-gnu@4.3.2': + resolution: {integrity: sha512-kqCZpSKOBEJO4mz7OqWoofBZeXTAwaVGPj0ErAj7CojmhKpWVWVOnrt9dE8odoIraZq4oj3ausM37kXi+Tow8w==} engines: {node: '>= 20'} cpu: [x64] os: [linux] - libc: [musl] + libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.3.0': resolution: {integrity: sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==} @@ -4580,8 +4558,15 @@ packages: os: [linux] libc: [musl] - '@tailwindcss/oxide-wasm32-wasi@4.2.1': - resolution: {integrity: sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==} + '@tailwindcss/oxide-linux-x64-musl@4.3.2': + resolution: {integrity: sha512-cixpqbh2toJDmkuCRI68nXA8ZxNmdK9Y+9v5h3MC3ZQKy/0BO8AWzlkWyRM7JAFSGBlfig4YVTPsK6MVgqz1uw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-wasm32-wasi@4.3.0': + resolution: {integrity: sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==} engines: {node: '>=14.0.0'} cpu: [wasm32] bundledDependencies: @@ -4592,8 +4577,8 @@ packages: - '@emnapi/wasi-threads' - tslib - '@tailwindcss/oxide-wasm32-wasi@4.3.0': - resolution: {integrity: sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==} + '@tailwindcss/oxide-wasm32-wasi@4.3.2': + resolution: {integrity: sha512-4ec2Z/LOmRsAgU23CS4xeJfcJlmRg94A/XrbGRCF1gyU/zdDfRLYDVsS+ynSZCmGNxQ1jQriQOKMQeQxBA3Isw==} engines: {node: '>=14.0.0'} cpu: [wasm32] bundledDependencies: @@ -4604,22 +4589,16 @@ packages: - '@emnapi/wasi-threads' - tslib - '@tailwindcss/oxide-win32-arm64-msvc@4.2.1': - resolution: {integrity: sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA==} - engines: {node: '>= 20'} - cpu: [arm64] - os: [win32] - '@tailwindcss/oxide-win32-arm64-msvc@4.3.0': resolution: {integrity: sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ==} engines: {node: '>= 20'} cpu: [arm64] os: [win32] - '@tailwindcss/oxide-win32-x64-msvc@4.2.1': - resolution: {integrity: sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ==} + '@tailwindcss/oxide-win32-arm64-msvc@4.3.2': + resolution: {integrity: sha512-Zyr/M0+XcYZu3bZrUytc7TXvrk0ftWfl8gN2MwekNDzhqhKRUucMPSeOzM0o0wH5AWOU49BsKRrfKxI2atCPMQ==} engines: {node: '>= 20'} - cpu: [x64] + cpu: [arm64] os: [win32] '@tailwindcss/oxide-win32-x64-msvc@4.3.0': @@ -4628,14 +4607,20 @@ packages: cpu: [x64] os: [win32] - '@tailwindcss/oxide@4.2.1': - resolution: {integrity: sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw==} + '@tailwindcss/oxide-win32-x64-msvc@4.3.2': + resolution: {integrity: sha512-QI9BO7KlNZsp2GuO0jwAAj5jCDABOKXRkCk2XuKTSaNEFSdfzqswYVTtCHBNKHLsqyjFyFkqlDiwkNbTYSssMQ==} engines: {node: '>= 20'} + cpu: [x64] + os: [win32] '@tailwindcss/oxide@4.3.0': resolution: {integrity: sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg==} engines: {node: '>= 20'} + '@tailwindcss/oxide@4.3.2': + resolution: {integrity: sha512-z8ZgnzX8gdNoWLBLqBPoh/sjnxkwvf9ZuWjnO0l0yIzbLa5/9S+eC5QxGZKRobVHIC3/1BoMWjHblqWjcgFgag==} + engines: {node: '>= 20'} + '@tailwindcss/vite@4.3.0': resolution: {integrity: sha512-t6J3OrB5Fc0ExuhohouH0fWUGMYL6PTLhW+E7zIk/pdbnJARZDCwjBznFnkh5ynRnIRSI4YjtTH0t6USjJISrw==} peerDependencies: @@ -4730,10 +4715,6 @@ packages: resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} engines: {node: '>=18'} - '@testing-library/jest-dom@6.9.1': - resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==} - engines: {node: '>=14', npm: '>=6', yarn: '>=1'} - '@testing-library/user-event@14.6.1': resolution: {integrity: sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==} engines: {node: '>=12', npm: '>=6'} @@ -4898,9 +4879,6 @@ packages: '@types/yargs@17.0.35': resolution: {integrity: sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==} - '@types/yauzl@2.10.3': - resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} - '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260604.1': resolution: {integrity: sha512-zs616um9UuaODLsNlCu5Aw95rFcTV4u3hVt090r6k0lVvTxfaJOv8HKA6BpIotcEYlZlMQowrMSYCCdedo7iyA==} engines: {node: '>=16.20.0'} @@ -5148,10 +5126,12 @@ packages: '@xmldom/xmldom@0.8.13': resolution: {integrity: sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==} engines: {node: '>=10.0.0'} + deprecated: this version has critical issues, please update to the latest version '@xmldom/xmldom@0.9.10': resolution: {integrity: sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==} engines: {node: '>=14.6'} + deprecated: this version has critical issues, please update to the latest version '@yuuang/ffi-rs-android-arm64@1.3.2': resolution: {integrity: sha512-eDYLT0kVBkp7e2BwdRDmt6N1rkeDPUHDefk3ZX0/nok+GLsqfy1WBoSL3Yg7HVXN1EyW8OBVc2uK8Zq8HbmaSA==} @@ -5254,6 +5234,11 @@ packages: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} + agent-cli-detector@0.1.6: + resolution: {integrity: sha512-vKrPeEVN3upDF3GjWxsWBbwQgMtNJ8VB1cduPvK3svmmz4ENpS7yaPxbogsbe3w+xp9xp2Cu+0Ar41rjAR4+lA==} + engines: {node: '>=18.18'} + hasBin: true + agent-install@0.0.5: resolution: {integrity: sha512-nHlms9BkP8ZiY79HrwCGiA2DcNaXrAaJrCM/BEqQ7MEsSKyCk+2A76xPGylIfASZSZE0SaU3T0bNSg4rBPIJAQ==} hasBin: true @@ -5492,33 +5477,21 @@ packages: babel-plugin-react-native-web@0.21.2: resolution: {integrity: sha512-SPD0J6qjJn8231i0HZhlAGH6NORe+QvRSQM2mwQEzJ2Fb3E4ruWTiiicPlHjmeWShDXLcvoorOCXjeR7k/lyWA==} - babel-plugin-syntax-hermes-parser@0.33.3: - resolution: {integrity: sha512-/Z9xYdaJ1lC0pT9do6TqCqhOSLfZ5Ot8D5za1p+feEfWYupCOfGbhhEXN9r2ZgJtDNUNRw/Z+T2CvAGKBqtqWA==} + babel-plugin-syntax-hermes-parser@0.36.0: + resolution: {integrity: sha512-LhD0xdoedDw7ansQgXbB2DADLZIK/LRXuWNBPuVzMc5S2WK5GyT89tCM+cQzxFGO0mGyLK6D5TrVOJJzAoDy8Q==} + + babel-plugin-syntax-hermes-parser@0.36.1: + resolution: {integrity: sha512-ycduwJbvdvIMmVvlAZqGggS+pm5Eu4Bk9pcV9Sm2Z4PJNRVsKkv0g7vHj+LeuC1gHTeF67sJXFOq61IlqCa2hA==} babel-plugin-transform-flow-enums@0.0.2: resolution: {integrity: sha512-g4aaCrDDOsWjbm0PUUeVnkcVd6AKJsVc/MbnPhEotEpkeJQP6b8nzewohQi7+QS8UyPehOhGWn0nOwjvWpmMvQ==} - babel-preset-expo@56.0.14: - resolution: {integrity: sha512-+JKVMYf3HajO3tPRA9DlKd/VhZOPTHyTzUo2yZajfMAoQ3l5VEdGVxm2MzX4DXMNKXwsC8GOeTRx7CrO/5dBDA==} - peerDependencies: - '@babel/runtime': ^7.20.0 - expo: '*' - expo-widgets: ^56.0.16 - react-refresh: '>=0.14.0 <1.0.0' - peerDependenciesMeta: - '@babel/runtime': - optional: true - expo: - optional: true - expo-widgets: - optional: true - - babel-preset-expo@56.0.15: - resolution: {integrity: sha512-0MqbQoM6nBUbKvgu2xJ4VixZnUTGTq3HB2WwvOikdO4CiPxbQ+wGA25fOoHHSni5iEFW39wy6y1ookTWlq3wVw==} + babel-preset-expo@57.0.9: + resolution: {integrity: sha512-T19biGOBTnMp161PyBqWOoSIEeZug8/EjtuUdVob8JPQKZMTalHUQaQt+qtQQE7XsEkZJ6erb3k0CohneTFzQg==} peerDependencies: '@babel/runtime': ^7.20.0 expo: '*' - expo-widgets: ^56.0.18 + expo-widgets: ^57.0.13 react-refresh: '>=0.14.0 <1.0.0' peerDependenciesMeta: '@babel/runtime': @@ -5624,9 +5597,6 @@ packages: resolution: {integrity: sha512-WIsKqkSC0ABoBJuT1LEX+2HEvNmNKKgnTAyd0fL8qzK4SH2i9NXg+t08YtdZp/V9IZ33cxe3iV4yM0qg8lMQng==} engines: {node: '>=16.20.1'} - buffer-crc32@0.2.13: - resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} - buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} @@ -5790,9 +5760,6 @@ packages: resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} engines: {node: '>= 12'} - client-only@0.0.1: - resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} - cliui@8.0.1: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} @@ -5985,9 +5952,6 @@ packages: resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==} engines: {node: '>= 6'} - css.escape@1.5.1: - resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} - csso@5.0.5: resolution: {integrity: sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==} engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} @@ -6124,9 +6088,6 @@ packages: dom-accessibility-api@0.5.16: resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} - dom-accessibility-api@0.6.3: - resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} - dom-serializer@2.0.0: resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} @@ -6348,9 +6309,9 @@ packages: resolution: {integrity: sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==} engines: {node: '>=8.0.0'} - electron@41.5.0: - resolution: {integrity: sha512-x9j9//PubUA4EjDtQbZhtk3prolandqCKgit0uCIqc1jb8FTskPbnJtxcDFB1aejczJcuERgjPixBUaMwoWyJg==} - engines: {node: '>= 12.20.55'} + electron@43.4.1: + resolution: {integrity: sha512-5b+EuiwkgG5iRcsEL34rimgRpkYp15SsfZOa0pC5kXs0Tb82TH4n95rpQzTZa7yRCbA7tm0WoEbuBL6NaAhAcA==} + engines: {node: '>= 22.12.0'} hasBin: true emmet@2.4.11: @@ -6373,6 +6334,10 @@ packages: end-of-stream@1.4.5: resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + enhanced-resolve@5.21.6: + resolution: {integrity: sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==} + engines: {node: '>=10.13.0'} + enhanced-resolve@5.22.1: resolution: {integrity: sha512-6QEuw3zoX1SJQc7b87aBXke/no+mG2bTBgw29gWMQonLmpEkWoCAVkl+M49e48AZlWzxiDzDZzYdp6kobcyLww==} engines: {node: '>=10.13.0'} @@ -6389,6 +6354,10 @@ packages: resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} engines: {node: '>=6'} + env-paths@3.0.0: + resolution: {integrity: sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + environment@1.1.0: resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} engines: {node: '>=18'} @@ -6492,38 +6461,46 @@ packages: resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} engines: {node: '>=12.0.0'} - expo-application@56.0.3: - resolution: {integrity: sha512-DdGGPlMuM6cSTeKhbvh6OeLr2O/+EI5BHKYrD+Do8sJPYgLwzGrgESELfyjJCpEhFzT+TgKIdmLmWXhNUQnHiw==} + expo-application@57.0.2: + resolution: {integrity: sha512-q31YwcXyymviAmdrtDfAg3Dld4VMxLCNAfgMHip7vZpPX4lzF/AfwsywqBJUAjQnJknzaNAkzqvMVjO5XmKYDA==} + peerDependencies: + expo: '*' + + expo-asset@57.0.15: + resolution: {integrity: sha512-ZoiUftQb1Nn1jvL6l1kZoUXvgRCx57tMQYxSsp+g4ZvY+jgVWp/THruXOtPtZVdC/WWZxPUTct53bfAm2EPDAQ==} peerDependencies: expo: '*' + react: '*' + react-native: '*' - expo-asset@56.0.17: - resolution: {integrity: sha512-GFN5j+8SPkyv0nfsiFHewmdB/D0tL237TsBE/gSfFOFy/J3a52py7IulcSqkA3sQE/u/UlD5BmvP5ssS4//nUg==} + expo-audio@57.0.4: + resolution: {integrity: sha512-TLP8rt1UvUDzgxGnyQ0TR9hV6tNP/UJQdDu7mSK+dgEydMFoptq55D3hcUoB1gF39f3/3AUuWfOtpGM+4N4X1A==} peerDependencies: expo: '*' + expo-asset: '*' react: '*' react-native: '*' - expo-auth-session@56.0.14: - resolution: {integrity: sha512-b6URDBKXVWBjHwypnbCPW6A3PrwYyFqzLXtTrrpTGpmDlsxk7xuz6wIA77sBNziz3hMxt11Nu70iY0fJZYT4jA==} + expo-auth-session@57.0.10: + resolution: {integrity: sha512-i1RY93LouEgal2e/Ly1r2Ytwed7/y6i4AUShVj4Gg49h/Okf8QpZO7XylwL43IHfKRrKkqapRDEN8mWe7z/HgA==} peerDependencies: react: '*' react-native: '*' - expo-blur@56.0.3: - resolution: {integrity: sha512-KDDtrpWc2tYlm1WCPaOgBtv+YEGqe5ELheFPIgSNgHt28NQUDcfBcFsA9Us2StDh6osmSD6NbKxOt5bU6PcDbQ==} + expo-blur@57.0.2: + resolution: {integrity: sha512-Aoud8H8lmlNkbRufyvRLefmGFELdBf1n5Te/Xm+Zx8ORINH+aXL+gKb5mbftFSha860+I7pMArz77TBYz8HDVg==} peerDependencies: expo: '*' react: '*' react-native: '*' - expo-build-properties@56.0.19: - resolution: {integrity: sha512-InoviXcxWosNp4cC7L3SWoiY99Xr2HdgN+LYHb6mUm/BBVxy1mIMrZR+3PJ2gwDZzW6EJNDz8ioASWGHBTmzpA==} + expo-build-properties@57.0.15: + resolution: {integrity: sha512-qZe9pnxlBpHT7SSNDR2a3C+P34xv67Ep9I0Erh+3xCczGlokyRd4pu9h0PrBdsVlr1d0QREQ1Do/+IgrIWkQQA==} peerDependencies: expo: '*' - expo-camera@56.0.8: - resolution: {integrity: sha512-UDOpUUMisFRmCv1XQV1MJCKGAH2CsIC1Rs6P9Bbc6JLVmbxEKAd5dK68y6cScOdWURxVfJ0PRcjYnSuc8ayyIQ==} + expo-camera@57.0.4: + resolution: {integrity: sha512-MqbbQ63O+cA8JbK3lfFHiD0f2jv8jB+EG/ILK6f5xnOPV9IR37yaG5+lGtRZNoOeYBzGypyOi17USItleMyuGw==} peerDependencies: expo: '*' react: '*' @@ -6533,86 +6510,96 @@ packages: react-native-web: optional: true - expo-clipboard@56.0.4: - resolution: {integrity: sha512-qb4DYlkiowHYHaUYVT2FN9nk/nI1xShXOUYsI7J9dVpQCOHcGFjCBPX1VAvEW4Ye4/Aagd6IuhOVAq/+scBOiA==} + expo-clipboard@57.0.1: + resolution: {integrity: sha512-HWICri4+1ao7S6QEfcorxVumXDiDnx1guGGewjZgGJWLGxFYs0RgH8ujBs+lkTzBkMmlwADaWSlaesR+nDJt5Q==} peerDependencies: expo: '*' react: '*' react-native: '*' - expo-constants@56.0.18: - resolution: {integrity: sha512-8AMtbDGl/WVPnWlmbpGmvcdnNCy9E4PFnwdVwj600vljkMDPSxcAcjw8GVXEPk3PpZ+ngTqsrkltWyj0UKYAxw==} + expo-constants@57.0.16: + resolution: {integrity: sha512-HG3yJjGZo2BPTo2F1sBoxdJZRwX7d9C554EhKn8m12K62pxGolCsvPSoh/lQf95OEP9vO34wwXB2C3QFs0KG0g==} peerDependencies: expo: '*' react-native: '*' - expo-crypto@56.0.4: - resolution: {integrity: sha512-fRNEhoXRXgAWBpe3/hq5X+KXTit3OZqdiAGts1YvNEUHQb+H5591mpPac0Yw+sZg9pXcrjRnzo5AxvZaENpc7g==} + expo-crypto@57.0.2: + resolution: {integrity: sha512-OGLvHK7Hb7qsONMWGB9pJw3n+iY4OnZQmM/KwQEHRMrrOmNg0Lxj9+tp9bj46aAMMzXx0FTVA2jUKoplpXPnkA==} peerDependencies: expo: '*' - expo-dev-client@56.0.20: - resolution: {integrity: sha512-KebW4r8HhIiRrPzs6ZqVhp/so8buyglAO1h4No0Ibr5C2XRnlIoGWCN4zC6rW7IsI3iKUXcofLAQV9OjoxjiwQ==} + expo-dev-client@57.0.16: + resolution: {integrity: sha512-Txss9sgzEFI0E+4I34E76vPiukeVDHnqCj0ZXy8SDijVM31/P6MCNniVbEZk7MBPQclD0aopfuaCFtkV8+r1sw==} peerDependencies: expo: '*' - expo-dev-launcher@56.0.20: - resolution: {integrity: sha512-cTuC3GkPl9CTwO3CKnVmEm9qoQ0WairhwvTh6qMlg+zr/QU/tdiU++uDBX67hf9+FuxQOkWGp5khFNosT+0cIg==} + expo-dev-launcher@57.0.16: + resolution: {integrity: sha512-jaBr6q4A5js/r465m2mRxN8A87YOi8I6PiIt8XIt4qfQWDgw6hqGovaF1AVFGT1Z5jxkjtgK8WNz/rAeypjqPQ==} peerDependencies: expo: '*' react-native: '*' - expo-dev-menu-interface@56.0.1: - resolution: {integrity: sha512-odATx0ZL/Kis10sKSBiKiGQxAB6coSi/KQtKcMhnQVNno6FkRh5/4e5BqcEvpq2rNMTiQp4ytNAQHtdwbPXvGA==} + expo-dev-menu-interface@57.0.0: + resolution: {integrity: sha512-F47VdzOHYc19FhI/jBgctpO8a5UskTIxG6a1E5t3W5gF8VImuvBQffdXXfLHhsuCl7dS3v3U0R45cleeVXO1Zg==} peerDependencies: expo: '*' - expo-dev-menu@56.0.17: - resolution: {integrity: sha512-OofRkOOZnaDriSav3JDN4NP2lsLt2eOa/Ryptr5nMD62SwnFyK4R6n6PkPVaDU3LSsZqndAJHmN6inS+oziayQ==} + expo-dev-menu@57.0.16: + resolution: {integrity: sha512-KzMHtAmnr0GqyU6MCrKLzIo/AfOjcToGhzK+pFeXlcqSPOrPJbTGlZqQ3E1o2G2jZMs+M1XvXh/VgnRyJUaRGw==} peerDependencies: expo: '*' react-native: '*' - expo-eas-client@56.0.1: - resolution: {integrity: sha512-r8h0ZIExacCrSRgY+ARfhMvFqosLHLJt1L7jyhvabfr1DN/ZDKDsYbovss2tzkpEUZGxZ3BPcB5epCwUsBBdOA==} + expo-device@57.0.1: + resolution: {integrity: sha512-jyEMDUticH+dhcL3GHa2aiifOvGXJsmb3oVT2R2q4i8bN7Bddy61+NkpMmuS2VAZrvoLQwf0TJJ/1vi1ukvutA==} + peerDependencies: + expo: '*' + + expo-document-picker@57.0.1: + resolution: {integrity: sha512-qBwM5oxDZ3I9kwFD3pUE1oK/WNv9artoEKO6UpqhQgNRr0XA1ALRVWYjkF4+ge9lUNDRehjTm/jenINkzqg84g==} + peerDependencies: + expo: '*' - expo-file-system@56.0.8: - resolution: {integrity: sha512-NrH41/8snGIBSbYicwVLB4txPdgCATd7ZYhMAGS3YJZ9GbnduhlAoV4/YCbGayjrbpE9bJb/6wegPL/zmvRMnQ==} + expo-eas-client@57.0.2: + resolution: {integrity: sha512-EfFiqUr0o9TvTOgMbqDiV1oIdG/d7kirhqtwa7roGmm9wF+CpXUz20d9YGzO6KzJWUYgdUgu4B6Ocv0jNJUdnQ==} + + expo-file-system@57.0.6: + resolution: {integrity: sha512-pm8PMYEW6BnVOCBJ7df9FcDmQtE1tqImuYphlfYe1ipQRLYtdCayRczJcbKIsnB3mqEyp4gC2gWmMbsAsAOjVg==} peerDependencies: expo: '*' react-native: '*' - expo-font@56.0.7: - resolution: {integrity: sha512-hpU/vRwPzsby9lPGkA4blDqLIIXYzoWnCZHr6PxvcWbY/uPObAiyhh6q+e0WYsB65SthK+PLH95jEnVag7fwEg==} + expo-font@57.0.2: + resolution: {integrity: sha512-eWR0CAdysgVqg8VewJ8b5wKh1pa6C8YShIEU3SP0+hlSqOk4djUwuJjn3P04KHVzL3hws9FrUuPxCNmd4TGung==} peerDependencies: expo: '*' react: '*' react-native: '*' - expo-glass-effect@56.0.4: - resolution: {integrity: sha512-xI9rXtDwi7RW82uAlfyaXO6+k21ApWJ2tHAWYqPr/FjfmZbKsgNJ4Q0iZzGPCwboqjTGxaRZ61SZxBl8hDt5iA==} + expo-glass-effect@57.0.1: + resolution: {integrity: sha512-m/n8maxqNcHk6ZDhuqXBfD5Kt1Iz3M8xykVgdB0iSCIXvF70IqWXmQhX8Psswhrp8eZ+3r0mAD0Jh/2gFA3QaA==} peerDependencies: expo: '*' react: '*' react-native: '*' - expo-haptics@56.0.3: - resolution: {integrity: sha512-ycoahZJnR9tWAVh/0mJYxbETtHRYaWjiWS8cHlP6aDGU6Q6Y8rZ5NKsuBwWw6HR2Pe30mfVFgbF2HrBR6gtYmw==} + expo-haptics@57.0.2: + resolution: {integrity: sha512-vPths6zTxxaGaemC7D1GKbw1iOnOAAL5oAcCFVSgVNWnuLCBefL1LkhRBaJsepiMXRh2y3vwPiXkPaf6d16blA==} peerDependencies: expo: '*' - expo-image-loader@56.0.3: - resolution: {integrity: sha512-JgUo4fUeU1ZC+z8iBFj8v7yoGQnZrLbOVPyNE+DWVrld55F2F6R1ck+rmdm/8TNWLz1LhNQfD7c3XYP1ZikxXA==} + expo-image-loader@57.0.1: + resolution: {integrity: sha512-uhrZKLT/cTl2mXyR28kPpVkS5O+PK9N1QA/07IFM4f5T4g0lTW1JHT3NEWwEEsGFldPmVX4j7LwUVVZxE+woug==} peerDependencies: expo: '*' - expo-image-picker@56.0.18: - resolution: {integrity: sha512-sCjQ8M27bhGUv2vUavIE+uWdYo79b2D7Q5h9B66BSDZ+Rd8YyLVSf7vYGfIzQ7nMVoENZ6c4xo/JiDkEeQ9iTg==} + expo-image-picker@57.0.14: + resolution: {integrity: sha512-NK9XBQqOtscbB/uRts1Gm7Oki6odMN3FQPWD6fSKmNUfKM69O6kcgU25lEXFTSLZ7sLl+AopHa2X0uILd8rjaQ==} peerDependencies: expo: '*' - expo-image@56.0.11: - resolution: {integrity: sha512-k2xwxGk14xi6zxmEGAU4rUTb1lK5qf0y0Qb8+Jaggnul0KaJJxcq9qvyDp9iyJBW35cp9isONAUnNtIiooZ/Pw==} + expo-image@57.0.3: + resolution: {integrity: sha512-EYfV8tIQxXLQPHhgZxc+bESUn2NcVw1U0RM28qqFeFfHyD1sBpIYJC2JTy24OtfBowYVoUyTvgf2ykWydiZVCw==} peerDependencies: expo: '*' react: '*' @@ -6622,53 +6609,53 @@ packages: react-native-web: optional: true - expo-json-utils@56.0.0: - resolution: {integrity: sha512-lUqyv9aIGDbYTQ5Nux2FnH2/Dz0w5uJ8Pr080eS0StXi2jr5OmuMNErpzUnpfnYOU55xKotd4AHv68PfV/ludg==} + expo-json-utils@57.0.1: + resolution: {integrity: sha512-cgTe1NqzQdYs/WN+3nIY5IZg8s0pb0xaTUbhYvxQDn137GbwRfHoGM2se3m3Vsl4Qu+B9G4RPEK5WJDEU2Do7g==} - expo-keep-awake@56.0.3: - resolution: {integrity: sha512-CLMJXtEiMKknD3Rpm8CRwE6ZJUzu2yCEmRk1sgfHAJ1zIbuEWY3dpPDubtsnuzWm+2k6Sru+yaFbYsvPWmTiBA==} + expo-keep-awake@57.0.1: + resolution: {integrity: sha512-28lkFImeXTS+bhAjuCFV7w7tW5bXg27BJVrxv+nC/nyYa86qEa0oFeHwqol6ha5k4pdVDQgBF09GM4A1k76Ssg==} peerDependencies: expo: '*' react: '*' - expo-linking@56.0.14: - resolution: {integrity: sha512-IvVQHWC+Cj4fK5qD3iEVYqpU2a4rLW0IpAAlGJ4MH+H1fyZiHh3eN6qg2WmoclOEPfYATSuEa+dQT6wfgVpXlQ==} + expo-linking@57.0.8: + resolution: {integrity: sha512-gv0ZNaF7tILbcvX8xinNbQr6GIGQStxeUAJseweP+LjC9ped7Wl6EeyIMBlDR83U6Da+8drVeqBQSMvwAXgytA==} peerDependencies: react: '*' react-native: '*' - expo-manifests@56.0.4: - resolution: {integrity: sha512-Fokawl2UkiExIF0bqGoblRFA8lYpROVD+EpvDwSW4LgqQyPwNua1gLSgHZjdl5GsVugfRMMWE3LHaibDyX93hw==} + expo-manifests@57.0.1: + resolution: {integrity: sha512-qB/mDG2dYdl+EvUeQuqP8KFYCFgFCQjJYdWIHo8SFBgDzMYmdF286DFY2M1M9Okr99wkb5M4tgA3aCcwv3aEQA==} peerDependencies: expo: '*' - expo-modules-autolinking@56.0.16: - resolution: {integrity: sha512-9JnL4N46P8ubDpDIfWolDn7nxU2j1rY67xY/dNVuyH0m+HG+r/JI16VYtjIf4COpZtEuFo4D3h3MBeFzGucMnw==} + expo-modules-autolinking@57.0.12: + resolution: {integrity: sha512-Q8KAlq37nLKsQ+HsS9NpQVpd5jCgqtu694TDUNHBUBpV9ViD82mRBh8Uug/h68RG9xnLS+kuL4nYaCuFRghHjg==} hasBin: true - expo-modules-core@56.0.17: - resolution: {integrity: sha512-5J8whnT7Ccp+BrFClLmpF76omBqn95VZExroTm01Dgjm4vpty1Rb7U3we+ZUceNHtRd07Lw30u7FNfDgIhEbRQ==} + expo-modules-core@57.0.14: + resolution: {integrity: sha512-Cg3aQnVsQhZcMOF/RFgPGzMuhIak5W9eR9ecwmFSeQ813r4/49ut6RMA6PqUuUKuPCUIRqnBAHfSyTm1W9nfzg==} peerDependencies: react: '*' react-native: '*' - react-native-worklets: ^0.7.4 || ^0.8.0 + react-native-worklets: ^0.7.4 || ^0.8.0 || ^0.9.0 || ^0.10.0 peerDependenciesMeta: react-native-worklets: optional: true - expo-modules-jsi@56.0.10: - resolution: {integrity: sha512-fHZcFpYO/o62GYa6fJyAQJZcAShzhoN0iMMDzbr7vD3ewET6e1vAlTonbEakN9F0VHEgBFJ4NREy87uwVcpCuA==} + expo-modules-jsi@57.0.6: + resolution: {integrity: sha512-WK0xFEe0FdZTCLCdfXK+HVNYfAmEbA6goecRxqjr0gh3XYRKAr9tikxqcM0ItLQoEPKntscPZhWcPLGvr97Tfw==} peerDependencies: react-native: '*' - expo-network@56.0.5: - resolution: {integrity: sha512-zmuyO95jayDY9jyUfOAlNp9XXJrJaAOkBXXLy0TS/nh2kppj7CHirRPkQ/tf0rsxhIL3AEd9nsRTiPtNsGT9Lw==} + expo-network@57.0.1: + resolution: {integrity: sha512-ndg+FbDDlz6XTpQ6aVuVgyvrYQwMkpcUAvZIXbwrGBbLTSWNzYC/gawYu1BAeDN7O6JTGY98GBVJBov/JH7LgQ==} peerDependencies: expo: '*' react: '*' - expo-notifications@56.0.18: - resolution: {integrity: sha512-HHnrwyCLC5srFojcHYS2KskbNroy9o2fwPKdyhjrdjjrBu4sNRKm4LepcuZjDy98cZKEm89WIPW8O45vut8Rgw==} + expo-notifications@57.0.15: + resolution: {integrity: sha512-H91z4WSFukSQP3dThgh6PCPFrT6VHeR+J2f7CuCOE72JV7YEGcBCir103QY58ocKsdgkodQpnkTU2m8LTHkWLg==} peerDependencies: expo: '*' react: '*' @@ -6686,84 +6673,52 @@ packages: peerDependencies: expo: '*' - expo-router@56.2.11: - resolution: {integrity: sha512-08DBTrKv3QanOc9u1JNxSEChW9c/qNFbQ0dO28OLvufWWfdSRkSdHmh365D2FgoZg1qaOzZPCDuL3tM6nGSfkQ==} - peerDependencies: - '@expo/log-box': ^56.0.13 - '@expo/metro-runtime': ^56.0.15 - '@testing-library/react-native': '>= 13.2.0' - expo: '*' - expo-constants: ^56.0.18 - expo-linking: ^56.0.14 - react: '*' - react-dom: '*' - react-native: '*' - react-native-gesture-handler: '*' - react-native-reanimated: '*' - react-native-safe-area-context: '>= 5.4.0' - react-native-screens: ^4.25.2 - react-native-web: '*' - react-server-dom-webpack: ~19.0.4 || ~19.1.5 || ~19.2.4 - peerDependenciesMeta: - '@testing-library/react-native': - optional: true - react-dom: - optional: true - react-native-gesture-handler: - optional: true - react-native-reanimated: - optional: true - react-native-web: - optional: true - react-server-dom-webpack: - optional: true - - expo-secure-store@56.0.4: - resolution: {integrity: sha512-hjEi/gmpdFFJ9lYbdp3k3p/WchV7Gi0Qt8jt/m/0WJadqQrskafHAlDxbZkII1cN3Yd7zp9Lvkeq3UfGhSwirQ==} + expo-secure-store@57.0.2: + resolution: {integrity: sha512-PhrPMKnI7YSObLEQZOoP9evB2ZOCv9kFuG2L7cZfNlOwWjGbir711PlppkXVoM3JE8vamDrTqzMLGv266FhJJg==} peerDependencies: expo: '*' - expo-server@56.0.5: - resolution: {integrity: sha512-SmM2p2g3Jrktpiazcst+OxhjSzOHXKAY4BPURHYHXvApzzoybMmrNF4IEZ8DKZ145BhSe4ydAmlEFCRTsdtgUQ==} + expo-server@57.0.3: + resolution: {integrity: sha512-aK+LdKzauHSGmsOStZtyxdzv0zWssCkxTw3m4QuOhfDSJsZaMRTd9O41d8ixU/QfELTbaJ0oRNcF7JFV/7O9YQ==} engines: {node: '>=20.16.0'} - expo-sharing@56.0.18: - resolution: {integrity: sha512-45w4BWNFmdTczp+fJX6YfwJrn9sX+VeRWz2VWLhauygcCrym44HtVDXX5yVYPB9TW9ZesLcEI+CCrCBNWL7smQ==} + expo-sharing@57.0.16: + resolution: {integrity: sha512-Z4ZFYLP8+EqIdUAiERYRI4/r7rtZDCRQ8Lj6jDG0xPF19B8nsFhYMgh7uyic+WulFEr+Xn7FeWgSDAuufkOyzw==} peerDependencies: expo: '*' react: '*' react-native: '*' - expo-splash-screen@56.0.10: - resolution: {integrity: sha512-vDIlo8hzt9HlCZQ0kSY66v83D1WEXOJbVMeyPDfXDu9tbDdPMNUyDpi4WGJXikAjxnAKfbt5Mv5NnEbxINy+VA==} + expo-splash-screen@57.0.8: + resolution: {integrity: sha512-BEsrKg4niYBZa5AFzWRyGqxA4ZemtBOPwbCy+C336iGYYPuZYLEgWJGmm2xD1gRfCdrM/TP+l2ONd86Zvk3KyA==} peerDependencies: expo: '*' - expo-sqlite@56.0.5: - resolution: {integrity: sha512-wHYRVLS5nUFEtli45wHaO+RjlRY8sQXyOSgENVk6I4zq7+FgySqjOk3YOYW6IKIMwhj5XzjMJO+pY8xKUy73Kw==} + expo-sqlite@57.0.2: + resolution: {integrity: sha512-5KVbT7BQFZlIcQBXKWvwBERK3ODF6PSqct27GQKukZzAjsx2B97R+mwtYqrTUtUbw71VJrQrdr9Yl2OWYC7UpA==} peerDependencies: expo: '*' react: '*' react-native: '*' - expo-structured-headers@56.0.0: - resolution: {integrity: sha512-Yv4x+SQxNnMQm4nu8NFfzx197YaDhdYH2N0u7tGErwWTmH9Tm1SAhqo7bLbBWLC9kf7W+kdzTshLU9rTiCXWGw==} + expo-structured-headers@57.0.0: + resolution: {integrity: sha512-//t9UNPbJSEysc2x4VKJG/u7Osvv5DYJWsET5bqt/B+qcD1by/JXvSQzX3Q/YAgA96xFPontrz6OAPLbO4JKEA==} - expo-symbols@56.0.6: - resolution: {integrity: sha512-BrA81DjcNafdj7gXVhdrExb9LtUiSVyOf/NavyMmDAHgHMY1GqeR5cnn1PSAZeYKnSgQhee/H89XUpAxtog5hg==} + expo-symbols@57.0.2: + resolution: {integrity: sha512-qZ0iqOflm5lZGwRsQ5Y8sDksw3GAUKwHSX1bJBoocXf7gu14vafIXXYWte+JT9VfXAUGyKodJhLHP/GoOrcNWg==} peerDependencies: expo: '*' expo-font: '*' react: '*' react-native: '*' - expo-updates-interface@56.0.2: - resolution: {integrity: sha512-eWTwSZ9y8vrULG2oBn2TQSSIwBGSq/TxGJ3jY6tuVS2FWH/ASRIiKs3zkUZTRoC3ZuV2alz0mUClYV7nNrFx8g==} + expo-updates-interface@57.0.1: + resolution: {integrity: sha512-+LUWwJ0gf/TEKMVdQAw/Gjih4dvrk+URgy24X9qEGKuuMDZqjBRm9T4yQyBVALGL5TTdPUaB6ILxx3lshm3pwQ==} peerDependencies: expo: '*' - expo-updates@56.0.19: - resolution: {integrity: sha512-tTSPYO5h8wDA6a+wQ2v/SRdnOdz29x0npGHCv+4Ev31Fz5r05Ii1Wgfh3BlTXNz8mikMReDsZCf6YN71YeQKpw==} + expo-updates@57.0.19: + resolution: {integrity: sha512-xX4KIUa8H2xKmiVDe/uEehD96MV6nyyF/OCrE+PBYfOEp6BbS8bYkHZfNFcoDCKMRuT6pg9JrCi8OmnoWlM/2A==} hasBin: true peerDependencies: expo: '*' @@ -6774,24 +6729,31 @@ packages: expo-dev-client: optional: true - expo-web-browser@56.0.5: - resolution: {integrity: sha512-kaN+wcR5lHwPCH1IgrU1XyPUQvBRzdF1TMp65uAF9iUCyipqYnmrvV87eqAmrdkFFopWVgU7FcxPu1UZw+gvUQ==} + expo-video@57.0.3: + resolution: {integrity: sha512-Z+rLdBSzICwoHm/HUxND5fm5nfgqiB+QPWAOKxmA8ScYwvxG1UGjZ6OaayvPc3GkT4aucsbycmFO0uUv/qnIhg==} + peerDependencies: + expo: '*' + react: '*' + react-native: '*' + + expo-web-browser@57.0.2: + resolution: {integrity: sha512-3vl5kvd7PB48ub6PpNIJUuPxO8xVa6D8RnIgNba6SXRwqFprOfeEZgwTgtm41kz0AAtvMOztUVNEUkwrHKjqMQ==} peerDependencies: expo: '*' react-native: '*' - expo-widgets@56.0.19: - resolution: {integrity: sha512-D2RWectoEalVdGwXyE2LX3L9T6q6jSKh8jjvk1K3JSE1qOcCVZk+TJtvBUveTq8OoLblkeFMXqQ2fHG+kg655w==} + expo-widgets@57.0.15: + resolution: {integrity: sha512-dlwbUCLaxOZFcIejxtOo95NyqJMa4Sop4NPzJEvvD2MwXERBqxkp8RExxVDAEiBRIdAYqC1u02SnIJWT/27f7g==} peerDependencies: expo: '*' react: '*' react-native: '*' - expo@56.0.12: - resolution: {integrity: sha512-FxgdI/Yqva6iJOThZIHfvxlKPxs4EC4uScUnEswwSArR/Fj9k430O13R590LcOQTsdNsjIs+GBHwjfoAY6vmAQ==} + expo@57.0.18: + resolution: {integrity: sha512-6nax9hJPhf9dWrstliXUABTJXDNIJrfJKcCtjSxuYVs2Yf127GIhKf3wsEYSFUl+LFdRbPPTNaweJePH159wZA==} hasBin: true peerDependencies: - '@expo/dom-webview': '*' + '@expo/dom-webview': 57.0.1 '@expo/metro-runtime': '*' react: '*' react-dom: '*' @@ -6826,11 +6788,6 @@ packages: extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} - extract-zip@2.0.1: - resolution: {integrity: sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==} - engines: {node: '>= 10.17.0'} - hasBin: true - fast-check@4.9.0: resolution: {integrity: sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg==} engines: {node: '>=12.17.0'} @@ -6889,9 +6846,6 @@ packages: fb-watchman@2.0.2: resolution: {integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==} - fd-slicer@1.1.0: - resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==} - fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -7149,21 +7103,30 @@ packages: headers-polyfill@4.0.3: resolution: {integrity: sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==} - hermes-compiler@250829098.0.10: - resolution: {integrity: sha512-TcRlZ0/TlyfJqquRFAWoyElVNnkdYRi/sEp4/Qy8/GYxjg8j2cS9D4MjuaQ+qimkmLN7AmO+44IznRf06mAr0w==} + heic-to@1.5.2: + resolution: {integrity: sha512-8Fns+lZHAWmz5U5IUxDeXKwIf3foBoKNPLxxFY4B0MkLjNuomEIHCoDbDE+x/llFK3NCEO1cu4+n3iUKY+Svmw==} - hermes-estree@0.33.3: - resolution: {integrity: sha512-6kzYZHCk8Fy1Uc+t3HGYyJn3OL4aeqKLTyina4UFtWl8I0kSL7OmKThaiX+Uh2f8nGw3mo4Ifxg0M5Zk3/Oeqg==} + hermes-compiler@250829098.0.17: + resolution: {integrity: sha512-qG1PXzTEtriF6oQLZF3vyHhSMxOdW5h2TqqLri0rdpstPustd2fSvRZQMVAPdlhgFwBfYnj3OUZtiO6LjYsEFw==} hermes-estree@0.35.0: resolution: {integrity: sha512-xVx5Opwy8Oo1I5yGpVRhCvWL/iV3M+ylksSKVNlxxD90cpDpR/AR1jLYqK8HWihm065a6UI3HeyAmYzwS8NOOg==} - hermes-parser@0.33.3: - resolution: {integrity: sha512-Yg3HgaG4CqgyowtYjX/FsnPAuZdHOqSMtnbpylbptsQ9nwwSKsy6uRWcGO5RK0EqiX12q8HvDWKgeAVajRO5DA==} + hermes-estree@0.36.0: + resolution: {integrity: sha512-A1+8zn5oss2CFP7pKsOaxorQG6FNIz1WU1VDqruLPPZl3LVgeE2C5xfFg8Ow6/Ow4mSslLLtYP1J3n38eKyW9w==} + + hermes-estree@0.36.1: + resolution: {integrity: sha512-guv1nQ6IJ7S83NRFPWc3SA7IBZrdNC9kapwOq6uXvF4wP+sDCgjzQbKPCoyYmoyZRzztF/n/c36l/rccCZSiCw==} hermes-parser@0.35.0: resolution: {integrity: sha512-9JLjeHxBx8T4CAsydZR49PNZUaix+WpQJwu9p2010lu+7Kwl6D/7wYFFJxoz+aXkaaClp9Zfg6W6/zVlSJORaA==} + hermes-parser@0.36.0: + resolution: {integrity: sha512-GdpwMmH5x6IpC1cijvcvYnlPB60Mh6kTSF/NFdYV/j56gYdi+0RIakYs+eqOV+bbO0SW7mgVVGSsTJxyPQfo3w==} + + hermes-parser@0.36.1: + resolution: {integrity: sha512-GApNk4zLHi2UWoWZZkx7LNCOSzLSc5lB55pZ/PhK7ycFeg7u5LcF88p/WbpIi1XUDtE0MpHE3uRR3u3KB7TjSQ==} + hoist-non-react-statics@3.3.2: resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==} @@ -7233,10 +7196,6 @@ packages: immediate@3.0.6: resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} - indent-string@4.0.0: - resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} - engines: {node: '>=8'} - indent-string@5.0.0: resolution: {integrity: sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==} engines: {node: '>=12'} @@ -7490,6 +7449,9 @@ packages: json-schema-typed@8.0.2: resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + json-schema@0.4.0: + resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==} + json-stringify-safe@5.0.1: resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==} @@ -7566,12 +7528,6 @@ packages: lighthouse-logger@1.4.2: resolution: {integrity: sha512-gPWxznF6TKmUHrOQjlVo2UbaL2EJ71mb2CCeRs/2qBpi4L/g4LUVc9+3lKQ6DTUZwJswfM7ainGrLO1+fOqa2g==} - lightningcss-android-arm64@1.31.1: - resolution: {integrity: sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [android] - lightningcss-android-arm64@1.32.0: resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} engines: {node: '>= 12.0.0'} @@ -7584,12 +7540,6 @@ packages: cpu: [arm64] os: [darwin] - lightningcss-darwin-arm64@1.31.1: - resolution: {integrity: sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [darwin] - lightningcss-darwin-arm64@1.32.0: resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} engines: {node: '>= 12.0.0'} @@ -7602,12 +7552,6 @@ packages: cpu: [x64] os: [darwin] - lightningcss-darwin-x64@1.31.1: - resolution: {integrity: sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [darwin] - lightningcss-darwin-x64@1.32.0: resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} engines: {node: '>= 12.0.0'} @@ -7620,12 +7564,6 @@ packages: cpu: [x64] os: [freebsd] - lightningcss-freebsd-x64@1.31.1: - resolution: {integrity: sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [freebsd] - lightningcss-freebsd-x64@1.32.0: resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} engines: {node: '>= 12.0.0'} @@ -7638,12 +7576,6 @@ packages: cpu: [arm] os: [linux] - lightningcss-linux-arm-gnueabihf@1.31.1: - resolution: {integrity: sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==} - engines: {node: '>= 12.0.0'} - cpu: [arm] - os: [linux] - lightningcss-linux-arm-gnueabihf@1.32.0: resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} engines: {node: '>= 12.0.0'} @@ -7657,13 +7589,6 @@ packages: os: [linux] libc: [glibc] - lightningcss-linux-arm64-gnu@1.31.1: - resolution: {integrity: sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [linux] - libc: [glibc] - lightningcss-linux-arm64-gnu@1.32.0: resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} engines: {node: '>= 12.0.0'} @@ -7678,13 +7603,6 @@ packages: os: [linux] libc: [musl] - lightningcss-linux-arm64-musl@1.31.1: - resolution: {integrity: sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [linux] - libc: [musl] - lightningcss-linux-arm64-musl@1.32.0: resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} @@ -7699,13 +7617,6 @@ packages: os: [linux] libc: [glibc] - lightningcss-linux-x64-gnu@1.31.1: - resolution: {integrity: sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [linux] - libc: [glibc] - lightningcss-linux-x64-gnu@1.32.0: resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} @@ -7720,13 +7631,6 @@ packages: os: [linux] libc: [musl] - lightningcss-linux-x64-musl@1.31.1: - resolution: {integrity: sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [linux] - libc: [musl] - lightningcss-linux-x64-musl@1.32.0: resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} @@ -7740,12 +7644,6 @@ packages: cpu: [arm64] os: [win32] - lightningcss-win32-arm64-msvc@1.31.1: - resolution: {integrity: sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [win32] - lightningcss-win32-arm64-msvc@1.32.0: resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} engines: {node: '>= 12.0.0'} @@ -7758,12 +7656,6 @@ packages: cpu: [x64] os: [win32] - lightningcss-win32-x64-msvc@1.31.1: - resolution: {integrity: sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [win32] - lightningcss-win32-x64-msvc@1.32.0: resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} engines: {node: '>= 12.0.0'} @@ -7774,10 +7666,6 @@ packages: resolution: {integrity: sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==} engines: {node: '>= 12.0.0'} - lightningcss@1.31.1: - resolution: {integrity: sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==} - engines: {node: '>= 12.0.0'} - lightningcss@1.32.0: resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} engines: {node: '>= 12.0.0'} @@ -7880,6 +7768,9 @@ packages: mdast-util-definitions@6.0.0: resolution: {integrity: sha512-scTllyX6pnYNZH/AIp/0ePz6s4cZtARxImwoPJ7kS42n+MnVsI4XbnG6d4ibehRIldYMWM2LD7ImQblVhUejVQ==} + mdast-util-directive@3.1.0: + resolution: {integrity: sha512-I3fNFt+DHmpWCYAT7quoM6lHf9wuqtI+oCOfvILnoicNIqjh5E3dEJWiXuYME2gNe8vl1iMQwyUHa7bgFmak6Q==} + mdast-util-find-and-replace@3.0.2: resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==} @@ -7962,63 +7853,124 @@ packages: resolution: {integrity: sha512-rvCfz8snl9h20VcvpOHxZuHP1SlAkv4HXbzw7nyyVwu6Eqo5PRerbakQ9XmUCOsRy70spJ37O+G1TK8oMzo48g==} engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + metro-babel-transformer@0.84.5: + resolution: {integrity: sha512-2WbHILKMiJUzfdjmGOQOqU1bWi9//gqiclc/tkk/AIsrrVw3efhZ1uhkOwMTxUEPOzqoo091H0olLmVZH5FHGQ==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + metro-cache-key@0.84.4: resolution: {integrity: sha512-wVO79aGrkYImpnaVS4+d5RrRBRPX31QtvKB3wKGBuiNSznduZTQHzsrJZRroFJSwnygrzdsGUtDQPuqqFjFdvw==} engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + metro-cache-key@0.84.5: + resolution: {integrity: sha512-3dPB2TnvGjjf0/9O7AXVQURKXuQNauTZE7WpTGTlR017Gh/B5y0m/2wcqxfveUguHSpu89KhVxCAlr2k/H7uhQ==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + metro-cache@0.84.4: resolution: {integrity: sha512-gpcFQdSLUwUCk71saKoE64jLFbx2nwTfVCcPSULMNT8QYq0p1eZZE29Jvd0HtT/UlhC3ZOutLxJME5xqD2JUZg==} engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + metro-cache@0.84.5: + resolution: {integrity: sha512-WHS0n2OxQqtwEjSeQFPePNrMvEFhmQcUQM9cRJMHByWoi/GMWFBEWOf7hVkAM/0KRutAXNbDlSu/cZB6CyxgQQ==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + metro-config@0.84.4: resolution: {integrity: sha512-PMotGDjXcXLWo2TMRH+VR99phFNgYTwqh4OoieIKK3yTJa1Jmkl+fZJxDO0jfBvNF+WESHciHvpNuBtXaF3B0Q==} engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + metro-config@0.84.5: + resolution: {integrity: sha512-zie+uN6oohscowi2S7ByU+wUw6CrT4ZxW9uAbONOObSxx86RGmnIAmjXHLkfmcdYoY7jzOPEbqcI6oeVmqyBQA==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + metro-core@0.84.4: resolution: {integrity: sha512-HONpWC5LGXZn3ffkd4Hu6AIrfE7j4Z0g0wMo/goV24WOB3lhuFZ40KgvaDiSw8iyQHloMYay5N/wPX+z8oN/PQ==} engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} - metro-file-map@0.84.4: + metro-core@0.84.5: + resolution: {integrity: sha512-xwm605hCi5Y6eJTTb8ZWo6pkUcoBEIyiQOfkZh5GwtDwUrP9SNhTQZhzJHrBCwwxlf3Ptl/pxWJgQ1rsNYMnrA==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + + metro-file-map@0.84.4: resolution: {integrity: sha512-KSVDi/u60hKPx++NLu3MTIvyjzNoJnFAF8PQFxaj1jiSka/wjw+Ua6sNuJ0TDHQv+7AAoFQxeMgaRAe8Yic5wQ==} engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + metro-file-map@0.84.5: + resolution: {integrity: sha512-mlm/JL8toSbSc2akpKIGmzvrVRSCgZ5vkbycI34oMLoOnLGuLyC8WTyVJ6P0hZG/usDaGwZSl/s9BCRriqjGJA==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + metro-minify-terser@0.84.4: resolution: {integrity: sha512-5qpbaVOMC7CPitIpuewzVeGw7E+C3ykbv2mqTjQLl85Z3annSVGlSCTcsZjqXZzjupfK4Ztj3dDc4kc44NZwtQ==} engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + metro-minify-terser@0.84.5: + resolution: {integrity: sha512-BJoFwCEDsYnagPqarayInv2+diCDNDdLlaof/p6s9w4gh+gc9HXYM+pDvsKGKKUumpZswNF3Z/ftTMqKl/5IBg==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + metro-resolver@0.84.4: resolution: {integrity: sha512-1qLgbxQ5ZGhhutuPot1Yp348ofDsATL2WkrHF65TobqTT9K3P9qJXw38bomk7ncp5B7OYMfWwtyBZo1lCV792A==} engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + metro-resolver@0.84.5: + resolution: {integrity: sha512-VSSnepg1k6LyCwtb6eirWdAWlpKwBG8Rdtsr1mU38rMelFyWgh3/QuMSiZIZAIjwg/fsa8GhW5/FO54CAUPCEA==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + metro-runtime@0.84.4: resolution: {integrity: sha512-Jibypds4g7AhzdRKY+kDoj51s5EXMwgyp5ddtlreDAsWefMdOx+agWqgm0H2XSZ/ueanHHVM89fnf5OJnlxa8Q==} engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + metro-runtime@0.84.5: + resolution: {integrity: sha512-U1m2+d1Pr+JO2/iVXBB2OfXXityz7tqwIorxfrT15IEgaHvpJBq/OHiqnOWPKJbUl3JcxjcdviZZOKk85oK4Qg==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + metro-source-map@0.84.4: resolution: {integrity: sha512-jbWkPxIesVuo1IWkvezmMJld6iu8nD62GsrZiV6jP37AOdbo4OBq1FJ+qkOg8sV05wAHB//jAbziuW0SlJfW4g==} engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + metro-source-map@0.84.5: + resolution: {integrity: sha512-2BtV5L9uPc49F13Gn5wiP6bX/EncqzqTIk2VL/0F/96Vo0YEOjluT/qktQjFODfqGFsucwnh5mPEAl/2jVEfeg==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + metro-symbolicate@0.84.4: resolution: {integrity: sha512-OnfpacxUqGPZQ27t8qK9mFa7uqHIlVWeqRqkCbvMvreEBiamEeOn8krKtcwgP5M4cYDPwuSmCTopHMVthqG4zA==} engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} hasBin: true + metro-symbolicate@0.84.5: + resolution: {integrity: sha512-rQ40zYDAkaWBN9yvjUuAD0ZpzBMZSoKyGYXnb5JrfbKjun7fTvfoLHL3KXFYenBTYZkQtlp4cKSCv/1utxFyOw==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + hasBin: true + metro-transform-plugins@0.84.4: resolution: {integrity: sha512-kehr6HbAecqD0/a3xLXobELdPaAmRAl8bel0qagPF4vhZtux93nS8S4eq2kgKt6J2GnQpVjSoW1PXdst04mwow==} engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + metro-transform-plugins@0.84.5: + resolution: {integrity: sha512-+InaSVGaOyt0DyRo4Y/zIdPI6CZwnbNho5LAL23tgmuGwv7fyfkF7kKfPjZcfxXBcoYdTLLFnCfCH/dHSiCqNg==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + metro-transform-worker@0.84.4: resolution: {integrity: sha512-W1IYMvvXTu4MxYr7d9h7CeG2vpIr3bmLLIavkPY4O1ilzDrvS8z/NEe6y+pC44Ff7raMXQgYSfdqDUwN/i39gg==} engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + metro-transform-worker@0.84.5: + resolution: {integrity: sha512-ui1Z8x4s5RL36gMmKLaMMO7O9NNDHNdthEZSCDQHAau3JcAsTaFOK6I+2q4I/kW5u8hSEjJk9L45TXSVJw6g1A==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + metro@0.84.4: resolution: {integrity: sha512-8ETTubqfD6ornDy2zYDvRcKnVDOXdFJsjetYDBsY4oAsb6NJkiwFR+FaMESyGppFmQUyBQA4H4sFGxzcQSGtFA==} engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} hasBin: true + metro@0.84.5: + resolution: {integrity: sha512-r1liLkyFZMVSEMNjU1CJU5pRzs3NdkxHqXS60O25c0rCIqAR+cGk7rPydw/g0WAIKVXojIBIF45yYBPagJGcgw==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + hasBin: true + micromark-core-commonmark@2.0.3: resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} + micromark-extension-directive@4.0.0: + resolution: {integrity: sha512-/C2nqVmXXmiseSSuCdItCMho7ybwwop6RrrRPk0KbOHW21JKoCldC+8rFOaundDoRBUWBnJJcxeA/Kvi34WQXg==} + micromark-extension-gfm-autolink-literal@2.1.0: resolution: {integrity: sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==} @@ -8159,10 +8111,6 @@ packages: resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} engines: {node: '>=10'} - min-indent@1.0.1: - resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} - engines: {node: '>=4'} - minimatch@10.2.5: resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} engines: {node: 18 || 20 || >=22} @@ -8261,8 +8209,8 @@ packages: multipasta@0.2.8: resolution: {integrity: sha512-ZPWuMKyv0cSO29f7hozp+k6+crZbQijV8ipMvxNxRf2SwtYGTX1ZX89Kd20VV4H9Znonx+EQn+iy1wGQsJ+b+Q==} - multitars@1.0.0: - resolution: {integrity: sha512-H/J4fMLedtudftaYMOg7ajzLYgT3/rwbWVJbqr/iUgB8DQztn38ys5HOqI1CzSxx8QhXXwOOnnBvd4v3jG5+Mg==} + multitars@1.0.2: + resolution: {integrity: sha512-6GwVw5eLi9sThdtlS4PKwC7yRLaf45pYhIEzKBHdKxi+YOXGKFX8acIniH+Uh/+k9mS2lQOupTccjoe5r0/1IQ==} mute-stream@2.0.0: resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} @@ -8404,6 +8352,10 @@ packages: resolution: {integrity: sha512-eJXMpz4aQHXF/YBB9ddqZDIS+ooO91hObo9FoW/xBkr54/zCwYYCDqT/O54vNo8kOkWs5Ou/y28NgdrV0edQNA==} engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + ob1@0.84.5: + resolution: {integrity: sha512-aH9RkoZc7w/90HBamFxTw8ZLFr05wXS+iOnvmrgo53Ep8Pyrm5FieQSaPIVROkfFVQISeD/zo92fes26TOwe+A==} + engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -8608,9 +8560,6 @@ packages: resolution: {integrity: sha512-eRWB5LBz7PpDu4PUlwT0PhnQfTQJlDDdPa35urV4Osrm0t0AqQFGn+UIkU3klZvwJ8KPO3VbBFsXquA6p6kqZw==} engines: {node: '>=12', npm: '>=6'} - pend@1.2.0: - resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} - pg-cloudflare@1.4.0: resolution: {integrity: sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==} @@ -8897,9 +8846,6 @@ packages: peerDependencies: react: ^18.0.0 || ^19.0.0 - react-fast-compare@3.2.2: - resolution: {integrity: sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==} - react-freeze@1.0.4: resolution: {integrity: sha512-r4F0Sec0BLxWicc7HEyo2x3/2icUTrRmDjaaRyzzn+7aDyFZliszMDOgLVwSnQnYENOlL1o569Ze2HZefk8clA==} engines: {node: '>=10'} @@ -8933,16 +8879,8 @@ packages: '@types/react': '>=18' react: '>=18' - react-native-drawer-layout@4.2.4: - resolution: {integrity: sha512-l1Le5HcVidobnJm8xqFZo46Rs8FDHdxbTZhkjxpNSRgU+QMoQXilOfzTHAeNjEGiKVGgIs9cW3ctXeHqgp5jJg==} - peerDependencies: - react: '>= 18.2.0' - react-native: '*' - react-native-gesture-handler: '>= 2.0.0' - react-native-reanimated: '>= 2.0.0' - - react-native-gesture-handler@2.31.2: - resolution: {integrity: sha512-rw5q74i2AfS7YGYdbxQDhOU7xqgY6WRM1132/CCm3erqjblhECZDZFHIm0tteHoC9ih24wogVBVVzcTBQtZ+5A==} + react-native-gesture-handler@2.32.0: + resolution: {integrity: sha512-uYIMOKlKENORq2SABE+jIjbPU+h5I/sQKcq2v16zRq848nwEp1fWRVwML4QWqijc8UcXJC25o54S8GQd4Mf2OA==} peerDependencies: react: '*' react-native: '*' @@ -8986,12 +8924,12 @@ packages: react: '*' react-native: '*' - react-native-reanimated@4.3.1: - resolution: {integrity: sha512-KhGsS0YkCA+gusgyzlf9hnqzVPIR398KTpqXyqq/+yYJJPAvyEEPKcxlB0xtOOXSMrR2A9uRKVARVQhZwrOh+Q==} + react-native-reanimated@4.5.1: + resolution: {integrity: sha512-RnMvtDuR+68ig864gAvZCOdZehqhC5rFmMo0kn+ARfgVSTvFeF6IFLBVgMPUu0KwihaapEyW24WRi6nEyy1kSA==} peerDependencies: react: '*' - react-native: 0.81 - 0.85 - react-native-worklets: 0.8.x + react-native: 0.83 - 0.86 + react-native-worklets: 0.10.x react-native-safe-area-context@5.7.0: resolution: {integrity: sha512-/9/MtQz8ODphjsLdZ+GZAIcC/RtoqW9EeShf7Uvnfgm/pzYrJ75y3PV/J1wuAV1T5Dye5ygq4EAW20RoBq0ABQ==} @@ -8999,11 +8937,11 @@ packages: react: '*' react-native: '*' - react-native-screens@4.25.2: - resolution: {integrity: sha512-1Nj1fusFd+rIMKU/qC9yGKVG+3ofh11d3OdBQKL1iVvQfKvcB8vhvTGQf2TkfxW3bamxN+hCZIXmNuU0mRkyDg==} + react-native-screens@4.26.2: + resolution: {integrity: sha512-2XnWsZToKj76trGtEZzx5ELD/qOICFEprEeUntImmitQFVUkea27fiWdUSITArI356Y1qynpXZINW+Unbhky/A==} peerDependencies: react: '*' - react-native: '>=0.82.0' + react-native: '*' react-native-shiki-engine@0.3.12: resolution: {integrity: sha512-CE6CA3uHGZT5OmY909H+vTv5lrmILV7zuPOF2pXRYXWN2qYm5KG7XacEB/Pq1U2+8D0zJoeMveTny/FHEqnipg==} @@ -9028,20 +8966,20 @@ packages: react: '*' react-native: '*' - react-native-worklets@0.8.3: - resolution: {integrity: sha512-oCBJROyLU7yG/1R8s0INMflygTH71bx+5XcYkH0CM938TlhSoVbiunE1WVW5FZa51vwYqfLie/IXMX2s1Kh3eg==} + react-native-worklets@0.10.1: + resolution: {integrity: sha512-62mRM19bDpfpdI8HLkEErcdOsrAPDtE9lA/sw+5lLRpzBHNhxaoj9QyY2KjXqUmirelxkX4zuPGTC3VdA0feJA==} peerDependencies: '@babel/core': '*' '@react-native/metro-config': '*' react: '*' - react-native: 0.81 - 0.85 + react-native: 0.83 - 0.86 - react-native@0.85.3: - resolution: {integrity: sha512-HN/fGC+3nZVcDNcw7gfbM/DuqZAvI9Mz+/SxuhODaua4JY0BPzhfTzWXRyTR4mRgMHmShTPpH2PYMTxvZrsdZA==} + react-native@0.86.3: + resolution: {integrity: sha512-JR5s3bM9ezud+Mw24GlNXNfthqPIKwrQgPPJcam+L97t2sKjjEavhCzBn+fyqZZRcM5+XlhYxpTxVkK7e1n38Q==} engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} hasBin: true peerDependencies: - '@react-native/jest-preset': 0.85.3 + '@react-native/jest-preset': 0.86.3 '@types/react': ^19.1.1 react: ^19.2.3 peerDependenciesMeta: @@ -9113,10 +9051,6 @@ packages: resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} engines: {node: '>= 20.19.0'} - redent@3.0.0: - resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} - engines: {node: '>=8'} - redis-errors@1.2.0: resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==} engines: {node: '>=4'} @@ -9309,6 +9243,11 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + sandbox-cli-detector@0.2.0: + resolution: {integrity: sha512-4lyHX0ZU0AZKwjgZ1InxZAa3PNpyEb8rOQ+Zss1ReYmhNzW0Q+h1zE5nvniXN0HaAWZaZE1zgVNEirb0R7LmNg==} + engines: {node: '>=18.18'} + hasBin: true + sanitize-filename@1.6.4: resolution: {integrity: sha512-9ZyI08PsvdQl2r/bBIGubpVdR3RR9sY6RDiWFPreA21C/EFlQhmgo20UZlNjZMMZNubusLhAQozkA0Od5J21Eg==} @@ -9381,9 +9320,6 @@ packages: resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} engines: {node: '>= 18'} - server-only@0.0.1: - resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==} - setimmediate@1.0.5: resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==} @@ -9394,9 +9330,6 @@ packages: resolution: {integrity: sha512-TPbeg0b7ylrswdGCji8FRGFAKuqbpQlLbL8SOle3j1iHSs5Ob5mhvMAxWN2UItOjgALAB5Zp3fmMfj8mbWvXKw==} engines: {node: '>=10'} - shallowequal@1.1.0: - resolution: {integrity: sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==} - sharp@0.34.5: resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -9543,9 +9476,6 @@ packages: standard-as-callback@2.1.0: resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} - standard-navigation@0.0.5: - resolution: {integrity: sha512-YAmzwAiiQVocZxO/VGPFiQHcu5pKiz09QIGC0MK6aRMoa3E0QkoTQgcqJr7ZZ3OMiNhu4DkaGElFI5htjOIDbw==} - standard-navigation@0.0.7: resolution: {integrity: sha512-NCGLCNyuXrFOkGHxdNZFnpsehGtiq1oXbPhKl7ZuxFO5J//H2evqqOchmD4YwEUJnkjO4kH9Xp4hQX6hdAYCKQ==} @@ -9612,10 +9542,6 @@ packages: resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} engines: {node: '>=12'} - strip-indent@3.0.0: - resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} - engines: {node: '>=8'} - strnum@2.3.0: resolution: {integrity: sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==} @@ -9671,12 +9597,12 @@ packages: tailwind-merge@3.6.0: resolution: {integrity: sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==} - tailwindcss@4.2.1: - resolution: {integrity: sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==} - tailwindcss@4.3.0: resolution: {integrity: sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==} + tailwindcss@4.3.2: + resolution: {integrity: sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA==} + tapable@2.3.3: resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} engines: {node: '>=6'} @@ -9841,6 +9767,10 @@ packages: engines: {node: '>=14.17'} hasBin: true + ua-parser-js@0.7.41: + resolution: {integrity: sha512-O3oYyCMPYgNNHuO7Jjk3uacJWZF8loBgwrfd/5LE/HyZ3lUIOdniQ7DNXJcIgZbwioZxk0fLfI4EVnetdiX5jg==} + hasBin: true + ufo@1.6.4: resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} @@ -9853,8 +9783,8 @@ packages: undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} - undici@6.26.0: - resolution: {integrity: sha512-4yqz8a3n5HmGTlsbADNtr/dJlhkh/55Rq798G6ibiULcXbDtaLpTl1pvdqcbFfeoj3iSi52lePFM7h9H21cw/A==} + undici@6.28.0: + resolution: {integrity: sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==} engines: {node: '>=18.17'} undici@7.27.1: @@ -9928,12 +9858,22 @@ packages: resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} engines: {node: '>= 10.0.0'} - uniwind@1.7.0: - resolution: {integrity: sha512-kIixWI3OprrWf/ypjglV2xubYvzCjtTKk110rQmXn8A3ZA/7z/nK6P/8EMxbfPCxjkdvhLcMniafa4qJcYIhZA==} + uniwind@1.11.0: + resolution: {integrity: sha512-e2mYlHrZzAQtZUf3STrfVDTetspv7p5vm/cu6p8kjLRQaL9r6QFG+MlwZ3hrI2LHnzgzjbSo+MwvoE380zz2JQ==} + hasBin: true peerDependencies: + '@expo/metro-config': 57.0.12 + metro: '*' + metro-cache: '*' + metro-transform-worker: '*' react: '>=19.0.0' react-native: '>=0.81.0' tailwindcss: '>=4' + peerDependenciesMeta: + '@expo/metro-config': + optional: true + metro-transform-worker: + optional: true unpipe@1.0.0: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} @@ -10435,9 +10375,6 @@ packages: resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} engines: {node: '>=12'} - yauzl@2.10.0: - resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==} - yjs@13.6.31: resolution: {integrity: sha512-Eq+5BRfbeGyqGVrTJL3bEcr8gKkxPuyuoHmAwpk52fDb8kOVMrfVSTRPd6yiGgX5Fskb96qCRjzjbRjrL4YEnw==} engines: {node: '>=16.0.0', npm: '>=8.0.0'} @@ -10500,8 +10437,17 @@ packages: snapshots: - '@adobe/css-tools@4.5.0': - optional: true + '@ai-sdk/provider-utils@4.0.49(zod@4.4.3)': + dependencies: + '@ai-sdk/provider': 3.0.15 + '@standard-schema/spec': 1.1.0 + eventsource-parser: 3.1.0 + undici: 6.28.0 + zod: 4.4.3 + + '@ai-sdk/provider@3.0.15': + dependencies: + json-schema: 0.4.0 '@alcalzone/ansi-tokenize@0.2.5': dependencies: @@ -11436,11 +11382,6 @@ snapshots: '@bruits/satteri-win32-x64-msvc@0.9.3': optional: true - '@callstack/liquid-glass@0.7.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': - dependencies: - react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - '@capsizecss/unpack@4.0.1': dependencies: fontkitten: 1.0.3 @@ -11470,16 +11411,16 @@ snapshots: '@clerk/backend@3.14.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@clerk/shared': 4.29.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@clerk/shared': 4.30.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) standardwebhooks: 1.0.0 tslib: 2.8.1 transitivePeerDependencies: - react - react-dom - '@clerk/clerk-js@6.29.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@clerk/clerk-js@6.30.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: - '@clerk/shared': 4.29.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@clerk/shared': 4.30.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@stripe/stripe-js': 5.6.0 '@swc/helpers': 0.5.21 '@tanstack/query-core': 5.100.14 @@ -11494,9 +11435,9 @@ snapshots: - react - react-dom - '@clerk/clerk-js@6.29.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@clerk/clerk-js@6.30.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@clerk/shared': 4.29.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@clerk/shared': 4.30.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@stripe/stripe-js': 5.6.0 '@swc/helpers': 0.5.21 '@tanstack/query-core': 5.100.14 @@ -11530,12 +11471,12 @@ snapshots: '@clerk/electron-passkeys-win32-arm64-msvc': 0.0.3 '@clerk/electron-passkeys-win32-x64-msvc': 0.0.3 - '@clerk/electron@0.0.34(@clerk/electron-passkeys@0.0.3)(electron-store@8.2.0)(electron@41.5.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@clerk/electron@0.0.37(@clerk/electron-passkeys@0.0.3)(electron-store@8.2.0)(electron@43.4.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@clerk/clerk-js': 6.29.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@clerk/react': 6.14.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@clerk/shared': 4.29.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - electron: 41.5.0 + '@clerk/clerk-js': 6.30.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@clerk/react': 6.14.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@clerk/shared': 4.30.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + electron: 43.4.1 react: 19.2.6 tslib: 2.8.1 optionalDependencies: @@ -11543,44 +11484,44 @@ snapshots: electron-store: 8.2.0 react-dom: 19.2.6(react@19.2.6) - '@clerk/expo@4.2.0(patch_hash=72e426f44fc1cde16fc2cbba3d1e96cdca7c6d957faa73d0fe6b43948608a6c1)(expo-auth-session@56.0.14(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(expo-constants@56.0.18)(expo-crypto@56.0.4(expo@56.0.12))(expo-secure-store@56.0.4(expo@56.0.12))(expo-web-browser@56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.12)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)': + '@clerk/expo@4.2.0(patch_hash=72e426f44fc1cde16fc2cbba3d1e96cdca7c6d957faa73d0fe6b43948608a6c1)(1fcd0592788ddcf326eeeb90d875ed47)': dependencies: - '@clerk/clerk-js': 6.29.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@clerk/react': 6.14.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@clerk/shared': 4.29.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@expo/config-plugins': 56.0.9(typescript@6.0.3) + '@clerk/clerk-js': 6.30.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@clerk/react': 6.14.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@clerk/shared': 4.30.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@expo/config-plugins': 57.0.9(typescript@6.0.3) base-64: 1.0.0 - expo: 56.0.12(8895228379997a2a064f9644cda56ed0) + expo: 57.0.18(f9c992a5d7c53d81398568d3950992dc) react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - react-native-url-polyfill: 4.0.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native-url-polyfill: 4.0.0(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) tslib: 2.8.1 optionalDependencies: - expo-auth-session: 56.0.14(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - expo-constants: 56.0.18(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) - expo-crypto: 56.0.4(expo@56.0.12) - expo-secure-store: 56.0.4(expo@56.0.12) - expo-web-browser: 56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + expo-auth-session: 57.0.10(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + expo-constants: 57.0.16(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + expo-crypto: 57.0.2(expo@57.0.18) + expo-secure-store: 57.0.2(expo@57.0.18) + expo-web-browser: 57.0.2(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) react-dom: 19.2.3(react@19.2.3) transitivePeerDependencies: - supports-color - typescript - '@clerk/react@6.14.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@clerk/react@6.14.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: - '@clerk/shared': 4.29.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@clerk/shared': 4.30.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3) react: 19.2.3 react-dom: 19.2.3(react@19.2.3) tslib: 2.8.1 - '@clerk/react@6.14.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@clerk/react@6.14.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@clerk/shared': 4.29.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@clerk/shared': 4.30.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) react: 19.2.6 react-dom: 19.2.6(react@19.2.6) tslib: 2.8.1 - '@clerk/shared@4.29.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@clerk/shared@4.30.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@tanstack/query-core': 5.100.14 dequal: 2.0.3 @@ -11590,7 +11531,7 @@ snapshots: react: 19.2.3 react-dom: 19.2.3(react@19.2.3) - '@clerk/shared@4.29.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@clerk/shared@4.30.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@tanstack/query-core': 5.100.14 dequal: 2.0.3 @@ -11839,6 +11780,8 @@ snapshots: dependencies: '@types/hammerjs': 2.0.46 + '@electron-internal/extract-zip@1.0.5': {} + '@electron/asar@3.4.1': dependencies: commander: 5.1.0 @@ -11851,7 +11794,7 @@ snapshots: fs-extra: 9.1.0 minimist: 1.2.8 - '@electron/get@2.0.3': + '@electron/get@3.1.0': dependencies: debug: 4.4.3 env-paths: 2.2.1 @@ -11865,17 +11808,16 @@ snapshots: transitivePeerDependencies: - supports-color - '@electron/get@3.1.0': + '@electron/get@5.1.0': dependencies: debug: 4.4.3 - env-paths: 2.2.1 - fs-extra: 8.1.0 - got: 11.8.6 + env-paths: 3.0.0 + graceful-fs: 4.2.11 progress: 2.0.3 - semver: 6.3.1 + semver: 7.8.5 sumchecker: 3.0.1 optionalDependencies: - global-agent: 3.0.0 + undici: 7.27.1 transitivePeerDependencies: - supports-color @@ -11898,6 +11840,15 @@ snapshots: transitivePeerDependencies: - supports-color + '@electron/osx-sign@2.7.0': + dependencies: + debug: 4.4.3 + isbinaryfile: 4.0.10 + plist: 3.1.1 + semver: 7.8.5 + transitivePeerDependencies: + - supports-color + '@electron/rebuild@4.0.4': dependencies: '@malept/cross-spawn-promise': 2.0.0 @@ -12151,32 +12102,33 @@ snapshots: '@expo-google-fonts/material-symbols@0.4.38': {} - '@expo/cli@56.1.16(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-constants@56.0.18)(expo-font@56.0.7)(expo-router@56.2.11)(expo@56.0.12)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6)': + '@expo/cli@57.0.20(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.14)(bufferutil@4.1.0)(expo-constants@57.0.16(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo-font@57.0.2(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(expo@57.0.18)(react-dom@19.2.3(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6)': dependencies: '@expo/code-signing-certificates': 0.0.6 - '@expo/config': 56.0.9(typescript@6.0.3) - '@expo/config-plugins': 56.0.9(typescript@6.0.3) + '@expo/config': 57.0.9(typescript@6.0.3) + '@expo/config-plugins': 57.0.9(typescript@6.0.3) '@expo/devcert': 1.2.1 - '@expo/env': 2.3.0 - '@expo/image-utils': 0.10.1(typescript@6.0.3) - '@expo/inline-modules': 0.0.12(typescript@6.0.3) - '@expo/json-file': 10.2.0 - '@expo/log-box': 56.0.13(@expo/dom-webview@56.0.5)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - '@expo/metro': 56.0.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) - '@expo/metro-config': 56.0.14(patch_hash=8cb08b5bb7051ed9d2dbe46a2c293c5a1e17f1bd6ddf30de27909e18c921ff46)(bufferutil@4.1.0)(expo@56.0.12)(typescript@6.0.3)(utf-8-validate@6.0.6) - '@expo/metro-file-map': 56.0.3 - '@expo/osascript': 2.6.0 - '@expo/package-manager': 1.12.1 - '@expo/plist': 0.7.0 - '@expo/prebuild-config': 56.0.16(typescript@6.0.3) - '@expo/require-utils': 56.1.3(typescript@6.0.3) - '@expo/router-server': 56.0.14(@expo/metro-runtime@56.0.15)(expo-constants@56.0.18)(expo-font@56.0.7)(expo-router@56.2.11)(expo-server@56.0.5)(expo@56.0.12)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@expo/schema-utils': 56.0.1 + '@expo/env': 2.4.3 + '@expo/image-utils': 0.11.5(typescript@6.0.3) + '@expo/inline-modules': 0.1.7(typescript@6.0.3) + '@expo/json-file': 11.0.1 + '@expo/log-box': 57.0.4(@expo/dom-webview@57.0.1)(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + '@expo/metro': 56.0.2(bufferutil@4.1.0)(utf-8-validate@6.0.6) + '@expo/metro-config': 57.0.12(patch_hash=96f1a75347e6ea02dc4b7034ace815d8ee39e18b8166ebfb573d9e58328f0dc2)(bufferutil@4.1.0)(expo@57.0.18)(typescript@6.0.3)(utf-8-validate@6.0.6) + '@expo/metro-file-map': 57.0.2 + '@expo/osascript': 2.7.1 + '@expo/package-manager': 1.13.1 + '@expo/plist': 0.8.1 + '@expo/prebuild-config': 57.0.15(typescript@6.0.3) + '@expo/require-utils': 57.0.5(typescript@6.0.3) + '@expo/router-server': 57.0.8(@expo/metro-runtime@57.0.14)(expo-constants@57.0.16(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo-font@57.0.2(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(expo-server@57.0.3)(expo@57.0.18)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@expo/schema-utils': 57.0.2 '@expo/spawn-async': 1.8.0 '@expo/ws-tunnel': 2.0.0(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)) '@expo/xcpretty': 4.4.4 - '@react-native/dev-middleware': 0.85.3(bufferutil@4.1.0)(utf-8-validate@6.0.6) + '@react-native/dev-middleware': 0.86.3(bufferutil@4.1.0)(utf-8-validate@6.0.6) accepts: 1.3.8 + agent-cli-detector: 0.1.6 arg: 5.0.2 bplist-creator: 0.1.0 bplist-parser: 0.3.2 @@ -12186,13 +12138,13 @@ snapshots: connect: 3.7.0 debug: 4.4.3 dnssd-advertise: 1.1.4 - expo: 56.0.12(8895228379997a2a064f9644cda56ed0) - expo-server: 56.0.5 + expo: 57.0.18(f9c992a5d7c53d81398568d3950992dc) + expo-server: 57.0.3 fetch-nodeshim: 0.4.10 getenv: 2.0.0 glob: 13.0.6 lan-network: 0.2.1 - multitars: 1.0.0 + multitars: 1.0.2 node-forge: 1.4.0 npm-package-arg: 11.0.3 ora: 3.4.0 @@ -12201,6 +12153,7 @@ snapshots: progress: 2.0.3 prompts: 2.4.2 resolve-from: 5.0.0 + sandbox-cli-detector: 0.2.0 semver: 7.8.5 send: 0.19.2 slugify: 1.6.9 @@ -12212,8 +12165,7 @@ snapshots: ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) zod: 3.25.76 optionalDependencies: - expo-router: 56.2.11(e1497a99e5bc5be76c1cdb733671f865) - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) transitivePeerDependencies: - '@expo/dom-webview' - '@expo/metro-runtime' @@ -12227,32 +12179,33 @@ snapshots: - typescript - utf-8-validate - '@expo/cli@56.1.16(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-constants@56.0.18)(expo-font@56.0.7)(expo-router@56.2.11)(expo@56.0.12)(react-dom@19.2.6(react@19.2.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)(typescript@6.0.3)(utf-8-validate@6.0.6)': + '@expo/cli@57.0.20(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.14)(bufferutil@4.1.0)(expo-constants@57.0.16(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6)))(expo-font@57.0.2(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6))(expo@57.0.18)(react-dom@19.2.6(react@19.2.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)(typescript@6.0.3)(utf-8-validate@6.0.6)': dependencies: '@expo/code-signing-certificates': 0.0.6 - '@expo/config': 56.0.9(typescript@6.0.3) - '@expo/config-plugins': 56.0.9(typescript@6.0.3) + '@expo/config': 57.0.9(typescript@6.0.3) + '@expo/config-plugins': 57.0.9(typescript@6.0.3) '@expo/devcert': 1.2.1 - '@expo/env': 2.3.0 - '@expo/image-utils': 0.10.1(typescript@6.0.3) - '@expo/inline-modules': 0.0.12(typescript@6.0.3) - '@expo/json-file': 10.2.0 - '@expo/log-box': 56.0.13(@expo/dom-webview@56.0.5)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) - '@expo/metro': 56.0.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) - '@expo/metro-config': 56.0.14(patch_hash=8cb08b5bb7051ed9d2dbe46a2c293c5a1e17f1bd6ddf30de27909e18c921ff46)(bufferutil@4.1.0)(expo@56.0.12)(typescript@6.0.3)(utf-8-validate@6.0.6) - '@expo/metro-file-map': 56.0.3 - '@expo/osascript': 2.6.0 - '@expo/package-manager': 1.12.1 - '@expo/plist': 0.7.0 - '@expo/prebuild-config': 56.0.16(typescript@6.0.3) - '@expo/require-utils': 56.1.3(typescript@6.0.3) - '@expo/router-server': 56.0.14(@expo/metro-runtime@56.0.15)(expo-constants@56.0.18)(expo-font@56.0.7)(expo-router@56.2.11)(expo-server@56.0.5)(expo@56.0.12)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@expo/schema-utils': 56.0.1 + '@expo/env': 2.4.3 + '@expo/image-utils': 0.11.5(typescript@6.0.3) + '@expo/inline-modules': 0.1.7(typescript@6.0.3) + '@expo/json-file': 11.0.1 + '@expo/log-box': 57.0.4(@expo/dom-webview@57.0.1)(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) + '@expo/metro': 56.0.2(bufferutil@4.1.0)(utf-8-validate@6.0.6) + '@expo/metro-config': 57.0.12(patch_hash=96f1a75347e6ea02dc4b7034ace815d8ee39e18b8166ebfb573d9e58328f0dc2)(bufferutil@4.1.0)(expo@57.0.18)(typescript@6.0.3)(utf-8-validate@6.0.6) + '@expo/metro-file-map': 57.0.2 + '@expo/osascript': 2.7.1 + '@expo/package-manager': 1.13.1 + '@expo/plist': 0.8.1 + '@expo/prebuild-config': 57.0.15(typescript@6.0.3) + '@expo/require-utils': 57.0.5(typescript@6.0.3) + '@expo/router-server': 57.0.8(@expo/metro-runtime@57.0.14)(expo-constants@57.0.16(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6)))(expo-font@57.0.2(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6))(expo-server@57.0.3)(expo@57.0.18)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@expo/schema-utils': 57.0.2 '@expo/spawn-async': 1.8.0 '@expo/ws-tunnel': 2.0.0(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)) '@expo/xcpretty': 4.4.4 - '@react-native/dev-middleware': 0.85.3(bufferutil@4.1.0)(utf-8-validate@6.0.6) + '@react-native/dev-middleware': 0.86.3(bufferutil@4.1.0)(utf-8-validate@6.0.6) accepts: 1.3.8 + agent-cli-detector: 0.1.6 arg: 5.0.2 bplist-creator: 0.1.0 bplist-parser: 0.3.2 @@ -12262,13 +12215,13 @@ snapshots: connect: 3.7.0 debug: 4.4.3 dnssd-advertise: 1.1.4 - expo: 56.0.12(deb8cbf3e0f411b34ba85a995c9982ba) - expo-server: 56.0.5 + expo: 57.0.18(ecb3896f1dbe5154a21ddc585503f306) + expo-server: 57.0.3 fetch-nodeshim: 0.4.10 getenv: 2.0.0 glob: 13.0.6 lan-network: 0.2.1 - multitars: 1.0.0 + multitars: 1.0.2 node-forge: 1.4.0 npm-package-arg: 11.0.3 ora: 3.4.0 @@ -12277,6 +12230,7 @@ snapshots: progress: 2.0.3 prompts: 2.4.2 resolve-from: 5.0.0 + sandbox-cli-detector: 0.2.0 semver: 7.8.5 send: 0.19.2 slugify: 1.6.9 @@ -12288,8 +12242,7 @@ snapshots: ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) zod: 3.25.76 optionalDependencies: - expo-router: 56.2.11(80beea6a31a5d2003a696c1401258797) - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) + react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) transitivePeerDependencies: - '@expo/dom-webview' - '@expo/metro-runtime' @@ -12308,12 +12261,12 @@ snapshots: dependencies: node-forge: 1.4.0 - '@expo/config-plugins@56.0.9(typescript@6.0.3)': + '@expo/config-plugins@57.0.9(typescript@6.0.3)': dependencies: - '@expo/config-types': 56.0.6 - '@expo/json-file': 10.2.0 - '@expo/plist': 0.7.0 - '@expo/require-utils': 56.1.3(typescript@6.0.3) + '@expo/config-types': 57.0.2 + '@expo/json-file': 11.0.1 + '@expo/plist': 0.8.1 + '@expo/require-utils': 57.0.5(typescript@6.0.3) '@expo/sdk-runtime-versions': 1.0.0 chalk: 4.1.2 debug: 4.4.3 @@ -12327,14 +12280,14 @@ snapshots: - supports-color - typescript - '@expo/config-types@56.0.6': {} + '@expo/config-types@57.0.2': {} - '@expo/config@56.0.9(typescript@6.0.3)': + '@expo/config@57.0.9(typescript@6.0.3)': dependencies: - '@expo/config-plugins': 56.0.9(typescript@6.0.3) - '@expo/config-types': 56.0.6 - '@expo/json-file': 10.2.0 - '@expo/require-utils': 56.1.3(typescript@6.0.3) + '@expo/config-plugins': 57.0.9(typescript@6.0.3) + '@expo/config-types': 57.0.2 + '@expo/json-file': 11.0.1 + '@expo/require-utils': 57.0.5(typescript@6.0.3) deepmerge: 4.3.1 getenv: 2.0.0 glob: 13.0.6 @@ -12352,35 +12305,35 @@ snapshots: transitivePeerDependencies: - supports-color - '@expo/devtools@56.0.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': + '@expo/devtools@57.0.1(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': dependencies: chalk: 4.1.2 optionalDependencies: react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - '@expo/devtools@56.0.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)': + '@expo/devtools@57.0.1(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)': dependencies: chalk: 4.1.2 optionalDependencies: react: 19.2.6 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) + react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) optional: true - '@expo/dom-webview@56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': + '@expo/dom-webview@57.0.1(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': dependencies: - expo: 56.0.12(8895228379997a2a064f9644cda56ed0) + expo: 57.0.18(f9c992a5d7c53d81398568d3950992dc) react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - '@expo/dom-webview@56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)': + '@expo/dom-webview@57.0.1(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)': dependencies: - expo: 56.0.12(deb8cbf3e0f411b34ba85a995c9982ba) + expo: 57.0.18(ecb3896f1dbe5154a21ddc585503f306) react: 19.2.6 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) + react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) optional: true - '@expo/env@2.3.0': + '@expo/env@2.4.3': dependencies: chalk: 4.1.2 debug: 4.4.3 @@ -12388,11 +12341,11 @@ snapshots: transitivePeerDependencies: - supports-color - '@expo/expo-modules-macros-plugin@0.2.2': {} + '@expo/expo-modules-macros-plugin@0.6.1': {} - '@expo/fingerprint@0.19.4': + '@expo/fingerprint@0.20.11': dependencies: - '@expo/env': 2.3.0 + '@expo/env': 2.4.3 '@expo/spawn-async': 1.8.0 arg: 5.0.2 chalk: 4.1.2 @@ -12406,9 +12359,9 @@ snapshots: transitivePeerDependencies: - supports-color - '@expo/image-utils@0.10.1(typescript@6.0.3)': + '@expo/image-utils@0.11.5(typescript@6.0.3)': dependencies: - '@expo/require-utils': 56.1.3(typescript@6.0.3) + '@expo/require-utils': 57.0.5(typescript@6.0.3) '@expo/spawn-async': 1.8.0 chalk: 4.1.2 getenv: 2.0.0 @@ -12432,55 +12385,55 @@ snapshots: - supports-color - typescript - '@expo/inline-modules@0.0.12(typescript@6.0.3)': + '@expo/inline-modules@0.1.7(typescript@6.0.3)': dependencies: - '@expo/config-plugins': 56.0.9(typescript@6.0.3) + '@expo/config-plugins': 57.0.9(typescript@6.0.3) transitivePeerDependencies: - supports-color - typescript - '@expo/json-file@10.2.0': + '@expo/json-file@11.0.1': dependencies: '@babel/code-frame': 7.29.7 json5: 2.2.3 - '@expo/local-build-cache-provider@56.0.8(typescript@6.0.3)': + '@expo/local-build-cache-provider@57.0.8(typescript@6.0.3)': dependencies: - '@expo/config': 56.0.9(typescript@6.0.3) + '@expo/config': 57.0.9(typescript@6.0.3) chalk: 4.1.2 transitivePeerDependencies: - supports-color - typescript - '@expo/log-box@56.0.13(@expo/dom-webview@56.0.5)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': + '@expo/log-box@57.0.4(@expo/dom-webview@57.0.1)(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': dependencies: - '@expo/dom-webview': 56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + '@expo/dom-webview': 57.0.1(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) anser: 1.4.10 - expo: 56.0.12(8895228379997a2a064f9644cda56ed0) + expo: 57.0.18(f9c992a5d7c53d81398568d3950992dc) react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) stacktrace-parser: 0.1.11 - '@expo/log-box@56.0.13(@expo/dom-webview@56.0.5)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)': + '@expo/log-box@57.0.4(@expo/dom-webview@57.0.1)(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)': dependencies: - '@expo/dom-webview': 56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) + '@expo/dom-webview': 57.0.1(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) anser: 1.4.10 - expo: 56.0.12(deb8cbf3e0f411b34ba85a995c9982ba) + expo: 57.0.18(ecb3896f1dbe5154a21ddc585503f306) react: 19.2.6 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) + react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) stacktrace-parser: 0.1.11 optional: true - '@expo/metro-config@56.0.14(patch_hash=8cb08b5bb7051ed9d2dbe46a2c293c5a1e17f1bd6ddf30de27909e18c921ff46)(bufferutil@4.1.0)(expo@56.0.12)(typescript@6.0.3)(utf-8-validate@6.0.6)': + '@expo/metro-config@57.0.12(patch_hash=96f1a75347e6ea02dc4b7034ace815d8ee39e18b8166ebfb573d9e58328f0dc2)(bufferutil@4.1.0)(expo@57.0.18)(typescript@6.0.3)(utf-8-validate@6.0.6)': dependencies: '@babel/code-frame': 7.29.7 '@babel/core': 7.29.7 '@babel/generator': 7.29.7 - '@expo/config': 56.0.9(typescript@6.0.3) - '@expo/env': 2.3.0 - '@expo/json-file': 10.2.0 - '@expo/metro': 56.0.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) - '@expo/require-utils': 56.1.3(typescript@6.0.3) + '@expo/config': 57.0.9(typescript@6.0.3) + '@expo/env': 2.4.3 + '@expo/json-file': 11.0.1 + '@expo/metro': 56.0.2(bufferutil@4.1.0)(utf-8-validate@6.0.6) + '@expo/require-utils': 57.0.5(typescript@6.0.3) '@expo/spawn-async': 1.8.0 '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/remapping': 2.3.5 @@ -12490,21 +12443,21 @@ snapshots: debug: 4.4.3 getenv: 2.0.0 glob: 13.0.6 - hermes-parser: 0.33.3 + hermes-parser: 0.36.1 jsc-safe-url: 0.2.4 lightningcss: 1.32.0 picomatch: 4.0.4 postcss: 8.5.15 resolve-from: 5.0.0 optionalDependencies: - expo: 56.0.12(8895228379997a2a064f9644cda56ed0) + expo: 57.0.18(f9c992a5d7c53d81398568d3950992dc) transitivePeerDependencies: - bufferutil - supports-color - typescript - utf-8-validate - '@expo/metro-file-map@56.0.3': + '@expo/metro-file-map@57.0.2': dependencies: debug: 4.4.3 fb-watchman: 2.0.2 @@ -12515,83 +12468,83 @@ snapshots: transitivePeerDependencies: - supports-color - '@expo/metro-runtime@56.0.15(@expo/log-box@56.0.13)(expo@56.0.12)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': + '@expo/metro-runtime@57.0.14(@expo/log-box@57.0.4)(expo@57.0.18)(react-dom@19.2.3(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': dependencies: - '@expo/log-box': 56.0.13(@expo/dom-webview@56.0.5)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + '@expo/log-box': 57.0.4(@expo/dom-webview@57.0.1)(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) anser: 1.4.10 - expo: 56.0.12(8895228379997a2a064f9644cda56ed0) + expo: 57.0.18(f9c992a5d7c53d81398568d3950992dc) pretty-format: 29.7.0 react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) stacktrace-parser: 0.1.11 whatwg-fetch: 3.6.20 optionalDependencies: react-dom: 19.2.3(react@19.2.3) - '@expo/metro-runtime@56.0.15(@expo/log-box@56.0.13)(expo@56.0.12)(react-dom@19.2.6(react@19.2.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)': + '@expo/metro-runtime@57.0.14(@expo/log-box@57.0.4)(expo@57.0.18)(react-dom@19.2.6(react@19.2.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)': dependencies: - '@expo/log-box': 56.0.13(@expo/dom-webview@56.0.5)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) + '@expo/log-box': 57.0.4(@expo/dom-webview@57.0.1)(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) anser: 1.4.10 - expo: 56.0.12(deb8cbf3e0f411b34ba85a995c9982ba) + expo: 57.0.18(ecb3896f1dbe5154a21ddc585503f306) pretty-format: 29.7.0 react: 19.2.6 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) + react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) stacktrace-parser: 0.1.11 whatwg-fetch: 3.6.20 optionalDependencies: react-dom: 19.2.6(react@19.2.6) optional: true - '@expo/metro@56.0.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)': - dependencies: - metro: 0.84.4(bufferutil@4.1.0)(utf-8-validate@6.0.6) - metro-babel-transformer: 0.84.4 - metro-cache: 0.84.4 - metro-cache-key: 0.84.4 - metro-config: 0.84.4(bufferutil@4.1.0)(utf-8-validate@6.0.6) - metro-core: 0.84.4 - metro-file-map: 0.84.4 - metro-minify-terser: 0.84.4 - metro-resolver: 0.84.4 - metro-runtime: 0.84.4 - metro-source-map: 0.84.4 - metro-symbolicate: 0.84.4 - metro-transform-plugins: 0.84.4 - metro-transform-worker: 0.84.4(bufferutil@4.1.0)(utf-8-validate@6.0.6) + '@expo/metro@56.0.2(bufferutil@4.1.0)(utf-8-validate@6.0.6)': + dependencies: + metro: 0.84.5(bufferutil@4.1.0)(utf-8-validate@6.0.6) + metro-babel-transformer: 0.84.5 + metro-cache: 0.84.5 + metro-cache-key: 0.84.5 + metro-config: 0.84.5(bufferutil@4.1.0)(utf-8-validate@6.0.6) + metro-core: 0.84.5 + metro-file-map: 0.84.5 + metro-minify-terser: 0.84.5 + metro-resolver: 0.84.5 + metro-runtime: 0.84.5 + metro-source-map: 0.84.5 + metro-symbolicate: 0.84.5 + metro-transform-plugins: 0.84.5 + metro-transform-worker: 0.84.5(bufferutil@4.1.0)(utf-8-validate@6.0.6) transitivePeerDependencies: - bufferutil - supports-color - utf-8-validate - '@expo/osascript@2.6.0': + '@expo/osascript@2.7.1': dependencies: '@expo/spawn-async': 1.8.0 - '@expo/package-manager@1.12.1': + '@expo/package-manager@1.13.1': dependencies: - '@expo/json-file': 10.2.0 + '@expo/json-file': 11.0.1 '@expo/spawn-async': 1.8.0 chalk: 4.1.2 npm-package-arg: 11.0.3 ora: 3.4.0 resolve-workspace-root: 2.0.1 - '@expo/plist@0.7.0': + '@expo/plist@0.8.1': dependencies: '@xmldom/xmldom': 0.8.13 base64-js: 1.5.1 xmlbuilder: 15.1.1 - '@expo/prebuild-config@56.0.16(typescript@6.0.3)': + '@expo/prebuild-config@57.0.15(typescript@6.0.3)': dependencies: - '@expo/config': 56.0.9(typescript@6.0.3) - '@expo/config-plugins': 56.0.9(typescript@6.0.3) - '@expo/config-types': 56.0.6 - '@expo/image-utils': 0.10.1(typescript@6.0.3) - '@expo/json-file': 10.2.0 - '@react-native/normalize-colors': 0.85.3 + '@expo/config': 57.0.9(typescript@6.0.3) + '@expo/config-plugins': 57.0.9(typescript@6.0.3) + '@expo/config-types': 57.0.2 + '@expo/image-utils': 0.11.5(typescript@6.0.3) + '@expo/json-file': 11.0.1 + '@react-native/normalize-colors': 0.86.3 debug: 4.4.3 - expo-modules-autolinking: 56.0.16(typescript@6.0.3) + expo-modules-autolinking: 57.0.12(typescript@6.0.3) resolve-from: 5.0.0 semver: 7.8.5 transitivePeerDependencies: @@ -12608,7 +12561,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@expo/require-utils@56.1.3(typescript@6.0.3)': + '@expo/require-utils@57.0.5(typescript@6.0.3)': dependencies: '@babel/code-frame': 7.29.7 '@babel/core': 7.29.7 @@ -12618,38 +12571,36 @@ snapshots: transitivePeerDependencies: - supports-color - '@expo/router-server@56.0.14(@expo/metro-runtime@56.0.15)(expo-constants@56.0.18)(expo-font@56.0.7)(expo-router@56.2.11)(expo-server@56.0.5)(expo@56.0.12)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@expo/router-server@57.0.8(@expo/metro-runtime@57.0.14)(expo-constants@57.0.16(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo-font@57.0.2(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(expo-server@57.0.3)(expo@57.0.18)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: debug: 4.4.3 - expo: 56.0.12(8895228379997a2a064f9644cda56ed0) - expo-constants: 56.0.18(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) - expo-font: 56.0.7(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - expo-server: 56.0.5 + expo: 57.0.18(f9c992a5d7c53d81398568d3950992dc) + expo-constants: 57.0.16(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + expo-font: 57.0.2(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + expo-server: 57.0.3 react: 19.2.3 optionalDependencies: - '@expo/metro-runtime': 56.0.15(@expo/log-box@56.0.13)(expo@56.0.12)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - expo-router: 56.2.11(e1497a99e5bc5be76c1cdb733671f865) + '@expo/metro-runtime': 57.0.14(@expo/log-box@57.0.4)(expo@57.0.18)(react-dom@19.2.3(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-dom: 19.2.3(react@19.2.3) transitivePeerDependencies: - supports-color - '@expo/router-server@56.0.14(@expo/metro-runtime@56.0.15)(expo-constants@56.0.18)(expo-font@56.0.7)(expo-router@56.2.11)(expo-server@56.0.5)(expo@56.0.12)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@expo/router-server@57.0.8(@expo/metro-runtime@57.0.14)(expo-constants@57.0.16(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6)))(expo-font@57.0.2(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6))(expo-server@57.0.3)(expo@57.0.18)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: debug: 4.4.3 - expo: 56.0.12(deb8cbf3e0f411b34ba85a995c9982ba) - expo-constants: 56.0.18(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6)) - expo-font: 56.0.7(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) - expo-server: 56.0.5 + expo: 57.0.18(ecb3896f1dbe5154a21ddc585503f306) + expo-constants: 57.0.16(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6)) + expo-font: 57.0.2(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) + expo-server: 57.0.3 react: 19.2.6 optionalDependencies: - '@expo/metro-runtime': 56.0.15(@expo/log-box@56.0.13)(expo@56.0.12)(react-dom@19.2.6(react@19.2.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) - expo-router: 56.2.11(80beea6a31a5d2003a696c1401258797) + '@expo/metro-runtime': 57.0.14(@expo/log-box@57.0.4)(expo@57.0.18)(react-dom@19.2.6(react@19.2.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) react-dom: 19.2.6(react@19.2.6) transitivePeerDependencies: - supports-color optional: true - '@expo/schema-utils@56.0.1': {} + '@expo/schema-utils@57.0.2': {} '@expo/sdk-runtime-versions@1.0.0': {} @@ -12659,35 +12610,17 @@ snapshots: '@expo/sudo-prompt@9.3.2': {} - '@expo/ui@56.0.18(32843e0c0883df8bccfa0b8323659df5)': - dependencies: - expo: 56.0.12(deb8cbf3e0f411b34ba85a995c9982ba) - react: 19.2.6 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) - sf-symbols-typescript: 2.2.0 - vaul: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - optionalDependencies: - '@babel/core': 7.29.7 - react-dom: 19.2.6(react@19.2.6) - react-native-reanimated: 4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) - react-native-worklets: 0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) - transitivePeerDependencies: - - '@types/react' - - '@types/react-dom' - optional: true - - '@expo/ui@56.0.18(3cdc0dde9f93166d952f1e1bd0cb25c0)': + '@expo/ui@57.0.14(@babel/core@7.29.7)(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(expo@57.0.18)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': dependencies: - expo: 56.0.12(8895228379997a2a064f9644cda56ed0) + expo: 57.0.18(f9c992a5d7c53d81398568d3950992dc) react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) sf-symbols-typescript: 2.2.0 vaul: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) optionalDependencies: '@babel/core': 7.29.7 react-dom: 19.2.3(react@19.2.3) - react-native-reanimated: 4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - react-native-worklets: 0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native-worklets: 0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) transitivePeerDependencies: - '@types/react' - '@types/react-dom' @@ -12869,7 +12802,8 @@ snapshots: '@img/sharp-win32-x64@0.34.5': optional: true - '@inquirer/ansi@1.0.2': {} + '@inquirer/ansi@1.0.2': + optional: true '@inquirer/confirm@5.1.21(@types/node@24.12.4)': dependencies: @@ -12877,6 +12811,7 @@ snapshots: '@inquirer/type': 3.0.10(@types/node@24.12.4) optionalDependencies: '@types/node': 24.12.4 + optional: true '@inquirer/core@10.3.2(@types/node@24.12.4)': dependencies: @@ -12890,12 +12825,15 @@ snapshots: yoctocolors-cjs: 2.1.3 optionalDependencies: '@types/node': 24.12.4 + optional: true - '@inquirer/figures@1.0.15': {} + '@inquirer/figures@1.0.15': + optional: true '@inquirer/type@3.0.10(@types/node@24.12.4)': optionalDependencies: '@types/node': 24.12.4 + optional: true '@ioredis/commands@1.10.0': {} @@ -12946,15 +12884,15 @@ snapshots: dependencies: jsbi: 4.3.2 - '@legendapp/list@3.3.5(patch_hash=064530db83875fa671559a81ae42dc159726dc2dd6ec4c982da44ff7c7a74706)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': + '@legendapp/list@3.3.5(patch_hash=03ec41339cd915ecb9a774a6b90cc2197c29038f7db67c4d2e55cd3971e5be43)(react-dom@19.2.3(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': dependencies: react: 19.2.3 use-sync-external-store: 1.6.0(react@19.2.3) optionalDependencies: react-dom: 19.2.3(react@19.2.3) - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - '@legendapp/list@3.3.5(patch_hash=064530db83875fa671559a81ae42dc159726dc2dd6ec4c982da44ff7c7a74706)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@legendapp/list@3.3.5(patch_hash=03ec41339cd915ecb9a774a6b90cc2197c29038f7db67c4d2e55cd3971e5be43)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: react: 19.2.6 use-sync-external-store: 1.6.0(react@19.2.6) @@ -13243,6 +13181,7 @@ snapshots: is-node-process: 1.2.0 outvariant: 1.4.3 strict-event-emitter: 0.5.1 + optional: true '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': dependencies: @@ -13357,14 +13296,17 @@ snapshots: '@octokit/request-error': 7.1.0 '@octokit/webhooks-methods': 6.0.0 - '@open-draft/deferred-promise@2.2.0': {} + '@open-draft/deferred-promise@2.2.0': + optional: true '@open-draft/logger@0.3.0': dependencies: is-node-process: 1.2.0 outvariant: 1.4.3 + optional: true - '@open-draft/until@2.1.0': {} + '@open-draft/until@2.1.0': + optional: true '@opencode-ai/sdk@1.15.13': dependencies: @@ -13603,58 +13545,18 @@ snapshots: '@radix-ui/primitive@1.1.3': {} - '@radix-ui/react-collection@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': - dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.16)(react@19.2.3) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.16)(react@19.2.3) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.16)(react@19.2.3) - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) - optionalDependencies: - '@types/react': 19.2.16 - '@types/react-dom': 19.2.3(@types/react@19.2.16) - optional: true - - '@radix-ui/react-collection@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': - dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.16)(react@19.2.6) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.16)(react@19.2.6) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.16)(react@19.2.6) - react: 19.2.6 - react-dom: 19.2.6(react@19.2.6) - optionalDependencies: - '@types/react': 19.2.16 - '@types/react-dom': 19.2.3(@types/react@19.2.16) - optional: true - '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.16)(react@19.2.3)': dependencies: react: 19.2.3 optionalDependencies: '@types/react': 19.2.16 - '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.16)(react@19.2.6)': - dependencies: - react: 19.2.6 - optionalDependencies: - '@types/react': 19.2.16 - optional: true - '@radix-ui/react-context@1.1.2(@types/react@19.2.16)(react@19.2.3)': dependencies: react: 19.2.3 optionalDependencies: '@types/react': 19.2.16 - '@radix-ui/react-context@1.1.2(@types/react@19.2.16)(react@19.2.6)': - dependencies: - react: 19.2.6 - optionalDependencies: - '@types/react': 19.2.16 - optional: true - '@radix-ui/react-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@radix-ui/primitive': 1.1.3 @@ -13677,43 +13579,6 @@ snapshots: '@types/react': 19.2.16 '@types/react-dom': 19.2.3(@types/react@19.2.16) - '@radix-ui/react-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.16)(react@19.2.6) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.16)(react@19.2.6) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.16)(react@19.2.6) - '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.16)(react@19.2.6) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.16)(react@19.2.6) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.16)(react@19.2.6) - aria-hidden: 1.2.6 - react: 19.2.6 - react-dom: 19.2.6(react@19.2.6) - react-remove-scroll: 2.7.2(@types/react@19.2.16)(react@19.2.6) - optionalDependencies: - '@types/react': 19.2.16 - '@types/react-dom': 19.2.3(@types/react@19.2.16) - optional: true - - '@radix-ui/react-direction@1.1.1(@types/react@19.2.16)(react@19.2.3)': - dependencies: - react: 19.2.3 - optionalDependencies: - '@types/react': 19.2.16 - optional: true - - '@radix-ui/react-direction@1.1.1(@types/react@19.2.16)(react@19.2.6)': - dependencies: - react: 19.2.6 - optionalDependencies: - '@types/react': 19.2.16 - optional: true - '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@radix-ui/primitive': 1.1.3 @@ -13727,33 +13592,12 @@ snapshots: '@types/react': 19.2.16 '@types/react-dom': 19.2.3(@types/react@19.2.16) - '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.16)(react@19.2.6) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.16)(react@19.2.6) - '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.16)(react@19.2.6) - react: 19.2.6 - react-dom: 19.2.6(react@19.2.6) - optionalDependencies: - '@types/react': 19.2.16 - '@types/react-dom': 19.2.3(@types/react@19.2.16) - optional: true - '@radix-ui/react-focus-guards@1.1.3(@types/react@19.2.16)(react@19.2.3)': dependencies: react: 19.2.3 optionalDependencies: '@types/react': 19.2.16 - '@radix-ui/react-focus-guards@1.1.3(@types/react@19.2.16)(react@19.2.6)': - dependencies: - react: 19.2.6 - optionalDependencies: - '@types/react': 19.2.16 - optional: true - '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.16)(react@19.2.3) @@ -13765,18 +13609,6 @@ snapshots: '@types/react': 19.2.16 '@types/react-dom': 19.2.3(@types/react@19.2.16) - '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': - dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.16)(react@19.2.6) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.16)(react@19.2.6) - react: 19.2.6 - react-dom: 19.2.6(react@19.2.6) - optionalDependencies: - '@types/react': 19.2.16 - '@types/react-dom': 19.2.3(@types/react@19.2.16) - optional: true - '@radix-ui/react-id@1.1.1(@types/react@19.2.16)(react@19.2.3)': dependencies: '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.16)(react@19.2.3) @@ -13784,14 +13616,6 @@ snapshots: optionalDependencies: '@types/react': 19.2.16 - '@radix-ui/react-id@1.1.1(@types/react@19.2.16)(react@19.2.6)': - dependencies: - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.16)(react@19.2.6) - react: 19.2.6 - optionalDependencies: - '@types/react': 19.2.16 - optional: true - '@radix-ui/react-portal@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -13802,17 +13626,6 @@ snapshots: '@types/react': 19.2.16 '@types/react-dom': 19.2.3(@types/react@19.2.16) - '@radix-ui/react-portal@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': - dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.16)(react@19.2.6) - react: 19.2.6 - react-dom: 19.2.6(react@19.2.6) - optionalDependencies: - '@types/react': 19.2.16 - '@types/react-dom': 19.2.3(@types/react@19.2.16) - optional: true - '@radix-ui/react-presence@1.1.5(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.16)(react@19.2.3) @@ -13823,17 +13636,6 @@ snapshots: '@types/react': 19.2.16 '@types/react-dom': 19.2.3(@types/react@19.2.16) - '@radix-ui/react-presence@1.1.5(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': - dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.16)(react@19.2.6) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.16)(react@19.2.6) - react: 19.2.6 - react-dom: 19.2.6(react@19.2.6) - optionalDependencies: - '@types/react': 19.2.16 - '@types/react-dom': 19.2.3(@types/react@19.2.16) - optional: true - '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@radix-ui/react-slot': 1.2.3(@types/react@19.2.16)(react@19.2.3) @@ -13843,176 +13645,40 @@ snapshots: '@types/react': 19.2.16 '@types/react-dom': 19.2.3(@types/react@19.2.16) - '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@radix-ui/react-slot@1.2.3(@types/react@19.2.16)(react@19.2.3)': dependencies: - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.16)(react@19.2.6) - react: 19.2.6 - react-dom: 19.2.6(react@19.2.6) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.16)(react@19.2.3) + react: 19.2.3 optionalDependencies: '@types/react': 19.2.16 - '@types/react-dom': 19.2.3(@types/react@19.2.16) - optional: true - '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.16)(react@19.2.3)': dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.16)(react@19.2.3) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.16)(react@19.2.3) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.16)(react@19.2.3) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.16)(react@19.2.3) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.16)(react@19.2.3) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.16)(react@19.2.3) react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) optionalDependencies: '@types/react': 19.2.16 - '@types/react-dom': 19.2.3(@types/react@19.2.16) - optional: true - '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.16)(react@19.2.3)': dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.16)(react@19.2.6) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.16)(react@19.2.6) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.16)(react@19.2.6) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.16)(react@19.2.6) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.16)(react@19.2.6) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.16)(react@19.2.6) - react: 19.2.6 - react-dom: 19.2.6(react@19.2.6) + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.16)(react@19.2.3) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.16)(react@19.2.3) + react: 19.2.3 optionalDependencies: '@types/react': 19.2.16 - '@types/react-dom': 19.2.3(@types/react@19.2.16) - optional: true - '@radix-ui/react-slot@1.2.3(@types/react@19.2.16)(react@19.2.3)': + '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.16)(react@19.2.3)': dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.16)(react@19.2.3) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.16)(react@19.2.3) react: 19.2.3 optionalDependencies: '@types/react': 19.2.16 - '@radix-ui/react-slot@1.2.3(@types/react@19.2.16)(react@19.2.6)': + '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.2.16)(react@19.2.3)': dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.16)(react@19.2.6) - react: 19.2.6 + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.16)(react@19.2.3) + react: 19.2.3 optionalDependencies: '@types/react': 19.2.16 - optional: true - - '@radix-ui/react-slot@1.2.4(@types/react@19.2.16)(react@19.2.3)': - dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.16)(react@19.2.3) - react: 19.2.3 - optionalDependencies: - '@types/react': 19.2.16 - optional: true - - '@radix-ui/react-slot@1.2.4(@types/react@19.2.16)(react@19.2.6)': - dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.16)(react@19.2.6) - react: 19.2.6 - optionalDependencies: - '@types/react': 19.2.16 - optional: true - - '@radix-ui/react-tabs@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-context': 1.1.2(@types/react@19.2.16)(react@19.2.3) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.16)(react@19.2.3) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.16)(react@19.2.3) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.16)(react@19.2.3) - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) - optionalDependencies: - '@types/react': 19.2.16 - '@types/react-dom': 19.2.3(@types/react@19.2.16) - optional: true - - '@radix-ui/react-tabs@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-context': 1.1.2(@types/react@19.2.16)(react@19.2.6) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.16)(react@19.2.6) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.16)(react@19.2.6) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.16)(react@19.2.6) - react: 19.2.6 - react-dom: 19.2.6(react@19.2.6) - optionalDependencies: - '@types/react': 19.2.16 - '@types/react-dom': 19.2.3(@types/react@19.2.16) - optional: true - - '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.16)(react@19.2.3)': - dependencies: - react: 19.2.3 - optionalDependencies: - '@types/react': 19.2.16 - - '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.16)(react@19.2.6)': - dependencies: - react: 19.2.6 - optionalDependencies: - '@types/react': 19.2.16 - optional: true - - '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.16)(react@19.2.3)': - dependencies: - '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.16)(react@19.2.3) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.16)(react@19.2.3) - react: 19.2.3 - optionalDependencies: - '@types/react': 19.2.16 - - '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.16)(react@19.2.6)': - dependencies: - '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.16)(react@19.2.6) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.16)(react@19.2.6) - react: 19.2.6 - optionalDependencies: - '@types/react': 19.2.16 - optional: true - - '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.16)(react@19.2.3)': - dependencies: - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.16)(react@19.2.3) - react: 19.2.3 - optionalDependencies: - '@types/react': 19.2.16 - - '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.16)(react@19.2.6)': - dependencies: - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.16)(react@19.2.6) - react: 19.2.6 - optionalDependencies: - '@types/react': 19.2.16 - optional: true - - '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.2.16)(react@19.2.3)': - dependencies: - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.16)(react@19.2.3) - react: 19.2.3 - optionalDependencies: - '@types/react': 19.2.16 - - '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.2.16)(react@19.2.6)': - dependencies: - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.16)(react@19.2.6) - react: 19.2.6 - optionalDependencies: - '@types/react': 19.2.16 - optional: true '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.16)(react@19.2.3)': dependencies: @@ -14020,13 +13686,6 @@ snapshots: optionalDependencies: '@types/react': 19.2.16 - '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.16)(react@19.2.6)': - dependencies: - react: 19.2.6 - optionalDependencies: - '@types/react': 19.2.16 - optional: true - '@react-grab/cli@0.1.44': dependencies: agent-install: 0.0.5 @@ -14038,34 +13697,35 @@ snapshots: prompts: 2.4.2 tinyexec: 1.2.4 - '@react-native-masked-view/masked-view@0.3.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': + '@react-native-ai/apple@0.12.0(patch_hash=2d09870c2848d185cb05b53ed823a46e12dba519324d8dd8e584e28731990f9d)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))': dependencies: - react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - optional: true + '@ai-sdk/provider': 3.0.15 + '@ai-sdk/provider-utils': 4.0.49(zod@4.4.3) + react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + zod: 4.4.3 - '@react-native-masked-view/masked-view@0.3.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)': + '@react-native-masked-view/masked-view@0.3.2(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': dependencies: - react: 19.2.6 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) + react: 19.2.3 + react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) optional: true - '@react-native-menu/menu@2.0.0(patch_hash=c7f66d121c726ade4f5c4e1aed11a691e5711d244c544084e289ac26132a0045)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': + '@react-native-menu/menu@2.0.0(patch_hash=f63d256bf6a97a873b5e628eb595bd6ef0075ddd5bdd890fc920f7a6024290dd)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': dependencies: react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - '@react-native/assets-registry@0.85.3': {} + '@react-native/assets-registry@0.86.3': {} - '@react-native/babel-plugin-codegen@0.85.3(@babel/core@7.29.7)': + '@react-native/babel-plugin-codegen@0.86.3(@babel/core@7.29.7)': dependencies: '@babel/traverse': 7.29.7 - '@react-native/codegen': 0.85.3(@babel/core@7.29.7) + '@react-native/codegen': 0.86.3(@babel/core@7.29.7) transitivePeerDependencies: - '@babel/core' - supports-color - '@react-native/babel-preset@0.85.3(@babel/core@7.29.7)': + '@react-native/babel-preset@0.86.3(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 '@babel/plugin-proposal-export-default-from': 7.29.7(@babel/core@7.29.7) @@ -14096,26 +13756,26 @@ snapshots: '@babel/plugin-transform-runtime': 7.29.7(@babel/core@7.29.7) '@babel/plugin-transform-typescript': 7.29.7(@babel/core@7.29.7) '@babel/plugin-transform-unicode-regex': 7.29.7(@babel/core@7.29.7) - '@react-native/babel-plugin-codegen': 0.85.3(@babel/core@7.29.7) - babel-plugin-syntax-hermes-parser: 0.33.3 + '@react-native/babel-plugin-codegen': 0.86.3(@babel/core@7.29.7) + babel-plugin-syntax-hermes-parser: 0.36.0 babel-plugin-transform-flow-enums: 0.0.2(@babel/core@7.29.7) react-refresh: 0.14.2 transitivePeerDependencies: - supports-color - '@react-native/codegen@0.85.3(@babel/core@7.29.7)': + '@react-native/codegen@0.86.3(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 '@babel/parser': 7.29.7 - hermes-parser: 0.33.3 + hermes-parser: 0.36.0 invariant: 2.2.4 nullthrows: 1.1.1 tinyglobby: 0.2.17 yargs: 17.7.2 - '@react-native/community-cli-plugin@0.85.3(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(bufferutil@4.1.0)(utf-8-validate@6.0.6)': + '@react-native/community-cli-plugin@0.86.3(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(bufferutil@4.1.0)(utf-8-validate@6.0.6)': dependencies: - '@react-native/dev-middleware': 0.85.3(bufferutil@4.1.0)(utf-8-validate@6.0.6) + '@react-native/dev-middleware': 0.86.3(bufferutil@4.1.0)(utf-8-validate@6.0.6) debug: 4.4.3 invariant: 2.2.4 metro: 0.84.4(bufferutil@4.1.0)(utf-8-validate@6.0.6) @@ -14123,15 +13783,15 @@ snapshots: metro-core: 0.84.4 semver: 7.8.5 optionalDependencies: - '@react-native/metro-config': 0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6) + '@react-native/metro-config': 0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6) transitivePeerDependencies: - bufferutil - supports-color - utf-8-validate - '@react-native/debugger-frontend@0.85.3': {} + '@react-native/debugger-frontend@0.86.3': {} - '@react-native/debugger-shell@0.85.3': + '@react-native/debugger-shell@0.86.3': dependencies: cross-spawn: 7.0.6 debug: 4.4.3 @@ -14139,11 +13799,11 @@ snapshots: transitivePeerDependencies: - supports-color - '@react-native/dev-middleware@0.85.3(bufferutil@4.1.0)(utf-8-validate@6.0.6)': + '@react-native/dev-middleware@0.86.3(bufferutil@4.1.0)(utf-8-validate@6.0.6)': dependencies: '@isaacs/ttlcache': 1.4.1 - '@react-native/debugger-frontend': 0.85.3 - '@react-native/debugger-shell': 0.85.3 + '@react-native/debugger-frontend': 0.86.3 + '@react-native/debugger-shell': 0.86.3 chrome-launcher: 0.15.2 chromium-edge-launcher: 0.3.0 connect: 3.7.0 @@ -14158,48 +13818,48 @@ snapshots: - supports-color - utf-8-validate - '@react-native/gradle-plugin@0.85.3(patch_hash=c1b594a16e682d621b6a960926f8ce13fc92edfb397884cfe7fcb0518996a784)': {} + '@react-native/gradle-plugin@0.86.3': {} - '@react-native/js-polyfills@0.85.3': {} + '@react-native/js-polyfills@0.86.3': {} - '@react-native/metro-babel-transformer@0.85.3(@babel/core@7.29.7)': + '@react-native/metro-babel-transformer@0.86.3(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@react-native/babel-preset': 0.85.3(@babel/core@7.29.7) - hermes-parser: 0.33.3 + '@react-native/babel-preset': 0.86.3(@babel/core@7.29.7) + hermes-parser: 0.36.0 nullthrows: 1.1.1 transitivePeerDependencies: - supports-color - '@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6)': + '@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6)': dependencies: - '@react-native/js-polyfills': 0.85.3 - '@react-native/metro-babel-transformer': 0.85.3(@babel/core@7.29.7) - metro-config: 0.84.4(bufferutil@4.1.0)(utf-8-validate@6.0.6) - metro-runtime: 0.84.4 + '@react-native/js-polyfills': 0.86.3 + '@react-native/metro-babel-transformer': 0.86.3(@babel/core@7.29.7) + metro-config: 0.84.5(bufferutil@4.1.0)(utf-8-validate@6.0.6) + metro-runtime: 0.84.5 transitivePeerDependencies: - '@babel/core' - bufferutil - supports-color - utf-8-validate - '@react-native/normalize-colors@0.85.3': {} + '@react-native/normalize-colors@0.86.3': {} - '@react-native/virtualized-lists@0.85.3(@types/react@19.2.16)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': + '@react-native/virtualized-lists@0.86.3(@types/react@19.2.16)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': dependencies: invariant: 2.2.4 nullthrows: 1.1.1 react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) optionalDependencies: '@types/react': 19.2.16 - '@react-native/virtualized-lists@0.85.3(@types/react@19.2.16)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)': + '@react-native/virtualized-lists@0.86.3(@types/react@19.2.16)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)': dependencies: invariant: 2.2.4 nullthrows: 1.1.1 react: 19.2.6 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) + react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) optionalDependencies: '@types/react': 19.2.16 optional: true @@ -14216,40 +13876,40 @@ snapshots: use-latest-callback: 0.2.6(react@19.2.3) use-sync-external-store: 1.6.0(react@19.2.3) - '@react-navigation/elements@2.9.26(c6a2ad0e2c930f8e3896e77c3ba11bc4)': + '@react-navigation/elements@2.9.26(c10301b6e0c42fc6434d2b643197a81e)': dependencies: - '@react-navigation/native': 7.3.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + '@react-navigation/native': 7.3.4(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) color: 4.2.3 react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - react-native-safe-area-context: 5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native-safe-area-context: 5.7.0(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) use-latest-callback: 0.2.6(react@19.2.3) use-sync-external-store: 1.6.0(react@19.2.3) optionalDependencies: - '@react-native-masked-view/masked-view': 0.3.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + '@react-native-masked-view/masked-view': 0.3.2(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - '@react-navigation/native-stack@7.17.6(patch_hash=0365b727005b3a830af80ccbd0b637666cc0338d33ddcb7a25b6de51a21ea027)(7ffd26361d0ffb9781446d1519df37be)': + '@react-navigation/native-stack@7.17.6(patch_hash=e667c3cef8c78bb9ff4882ee5bd23a432247b843060a9499eb5f07e9e2295552)(d307537762dff86bcf277a4ec64a11d8)': dependencies: - '@react-navigation/elements': 2.9.26(c6a2ad0e2c930f8e3896e77c3ba11bc4) - '@react-navigation/native': 7.3.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + '@react-navigation/elements': 2.9.26(c10301b6e0c42fc6434d2b643197a81e) + '@react-navigation/native': 7.3.4(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) color: 4.2.3 react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - react-native-safe-area-context: 5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - react-native-screens: 4.25.2(patch_hash=59bfd7b84af01708b6e581c4ccdd5ecf05f8b205383802d66b64ed7ea7bb2199)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native-safe-area-context: 5.7.0(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native-screens: 4.26.2(patch_hash=149bef30a66351ea9b26b42f87b78c539cb52b880f2387bb323ec80dcac84006)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) sf-symbols-typescript: 2.2.0 warn-once: 0.1.1 transitivePeerDependencies: - '@react-native-masked-view/masked-view' - '@react-navigation/native@7.3.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': + '@react-navigation/native@7.3.4(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': dependencies: '@react-navigation/core': 7.21.2(react@19.2.3) escape-string-regexp: 4.0.0 fast-deep-equal: 3.1.3 nanoid: 3.3.12 react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) standard-navigation: 0.0.7 use-latest-callback: 0.2.6(react@19.2.3) @@ -14589,134 +14249,121 @@ snapshots: dependencies: defer-to-connect: 2.0.1 - '@t3tools/mobile-markdown-text@file:apps/mobile/modules/t3-markdown-text(ed3009b8f2424467288a00b38bef28fe)': + '@t3tools/mobile-markdown-text@file:apps/mobile/modules/t3-markdown-text(cd0d4cdec0d5bee3af406af520908919)': dependencies: - expo-asset: 56.0.17(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3) - expo-clipboard: 56.0.4(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - expo-haptics: 56.0.3(expo@56.0.12) - expo-symbols: 56.0.6(expo-font@56.0.7)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + '@t3tools/client-runtime': link:packages/client-runtime + '@t3tools/shared': link:packages/shared + expo-asset: 57.0.15(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3) + expo-clipboard: 57.0.1(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + expo-haptics: 57.0.2(expo@57.0.18) + expo-symbols: 57.0.2(expo-font@57.0.2(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - react-native-nitro-markdown: 0.5.8(react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-svg@15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native-nitro-markdown: 0.5.8(react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-svg@15.15.4(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@t3tools/mobile-review-diff-native@file:apps/mobile/modules/t3-review-diff': {} '@t3tools/mobile-terminal-native@file:apps/mobile/modules/t3-terminal': {} - '@tabler/icons-react-native@3.44.0(react-native-svg@15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react@19.2.3)': + '@tabler/icons-react-native@3.44.0(react-native-svg@15.15.4(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react@19.2.3)': dependencies: '@tabler/icons': 3.44.0 react: 19.2.3 - react-native-svg: 15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native-svg: 15.15.4(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@tabler/icons@3.44.0': {} - '@tailwindcss/node@4.2.1': + '@tailwindcss/node@4.3.0': dependencies: '@jridgewell/remapping': 2.3.5 enhanced-resolve: 5.22.1 jiti: 2.7.0 - lightningcss: 1.31.1 + lightningcss: 1.32.0 magic-string: 0.30.21 source-map-js: 1.2.1 - tailwindcss: 4.2.1 + tailwindcss: 4.3.0 - '@tailwindcss/node@4.3.0': + '@tailwindcss/node@4.3.2': dependencies: '@jridgewell/remapping': 2.3.5 - enhanced-resolve: 5.22.1 + enhanced-resolve: 5.21.6 jiti: 2.7.0 lightningcss: 1.32.0 magic-string: 0.30.21 source-map-js: 1.2.1 - tailwindcss: 4.3.0 - - '@tailwindcss/oxide-android-arm64@4.2.1': - optional: true + tailwindcss: 4.3.2 '@tailwindcss/oxide-android-arm64@4.3.0': optional: true - '@tailwindcss/oxide-darwin-arm64@4.2.1': + '@tailwindcss/oxide-android-arm64@4.3.2': optional: true '@tailwindcss/oxide-darwin-arm64@4.3.0': optional: true - '@tailwindcss/oxide-darwin-x64@4.2.1': + '@tailwindcss/oxide-darwin-arm64@4.3.2': optional: true '@tailwindcss/oxide-darwin-x64@4.3.0': optional: true - '@tailwindcss/oxide-freebsd-x64@4.2.1': + '@tailwindcss/oxide-darwin-x64@4.3.2': optional: true '@tailwindcss/oxide-freebsd-x64@4.3.0': optional: true - '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.1': + '@tailwindcss/oxide-freebsd-x64@4.3.2': optional: true '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.0': optional: true - '@tailwindcss/oxide-linux-arm64-gnu@4.2.1': + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.2': optional: true '@tailwindcss/oxide-linux-arm64-gnu@4.3.0': optional: true - '@tailwindcss/oxide-linux-arm64-musl@4.2.1': + '@tailwindcss/oxide-linux-arm64-gnu@4.3.2': optional: true '@tailwindcss/oxide-linux-arm64-musl@4.3.0': optional: true - '@tailwindcss/oxide-linux-x64-gnu@4.2.1': + '@tailwindcss/oxide-linux-arm64-musl@4.3.2': optional: true '@tailwindcss/oxide-linux-x64-gnu@4.3.0': optional: true - '@tailwindcss/oxide-linux-x64-musl@4.2.1': + '@tailwindcss/oxide-linux-x64-gnu@4.3.2': optional: true '@tailwindcss/oxide-linux-x64-musl@4.3.0': optional: true - '@tailwindcss/oxide-wasm32-wasi@4.2.1': + '@tailwindcss/oxide-linux-x64-musl@4.3.2': optional: true '@tailwindcss/oxide-wasm32-wasi@4.3.0': optional: true - '@tailwindcss/oxide-win32-arm64-msvc@4.2.1': + '@tailwindcss/oxide-wasm32-wasi@4.3.2': optional: true '@tailwindcss/oxide-win32-arm64-msvc@4.3.0': optional: true - '@tailwindcss/oxide-win32-x64-msvc@4.2.1': + '@tailwindcss/oxide-win32-arm64-msvc@4.3.2': optional: true '@tailwindcss/oxide-win32-x64-msvc@4.3.0': optional: true - '@tailwindcss/oxide@4.2.1': - optionalDependencies: - '@tailwindcss/oxide-android-arm64': 4.2.1 - '@tailwindcss/oxide-darwin-arm64': 4.2.1 - '@tailwindcss/oxide-darwin-x64': 4.2.1 - '@tailwindcss/oxide-freebsd-x64': 4.2.1 - '@tailwindcss/oxide-linux-arm-gnueabihf': 4.2.1 - '@tailwindcss/oxide-linux-arm64-gnu': 4.2.1 - '@tailwindcss/oxide-linux-arm64-musl': 4.2.1 - '@tailwindcss/oxide-linux-x64-gnu': 4.2.1 - '@tailwindcss/oxide-linux-x64-musl': 4.2.1 - '@tailwindcss/oxide-wasm32-wasi': 4.2.1 - '@tailwindcss/oxide-win32-arm64-msvc': 4.2.1 - '@tailwindcss/oxide-win32-x64-msvc': 4.2.1 + '@tailwindcss/oxide-win32-x64-msvc@4.3.2': + optional: true '@tailwindcss/oxide@4.3.0': optionalDependencies: @@ -14733,6 +14380,21 @@ snapshots: '@tailwindcss/oxide-win32-arm64-msvc': 4.3.0 '@tailwindcss/oxide-win32-x64-msvc': 4.3.0 + '@tailwindcss/oxide@4.3.2': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.3.2 + '@tailwindcss/oxide-darwin-arm64': 4.3.2 + '@tailwindcss/oxide-darwin-x64': 4.3.2 + '@tailwindcss/oxide-freebsd-x64': 4.3.2 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.2 + '@tailwindcss/oxide-linux-arm64-gnu': 4.3.2 + '@tailwindcss/oxide-linux-arm64-musl': 4.3.2 + '@tailwindcss/oxide-linux-x64-gnu': 4.3.2 + '@tailwindcss/oxide-linux-x64-musl': 4.3.2 + '@tailwindcss/oxide-wasm32-wasi': 4.3.2 + '@tailwindcss/oxide-win32-arm64-msvc': 4.3.2 + '@tailwindcss/oxide-win32-x64-msvc': 4.3.2 + '@tailwindcss/vite@4.3.0(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))': dependencies: '@tailwindcss/node': 4.3.0 @@ -14853,16 +14515,6 @@ snapshots: picocolors: 1.1.1 pretty-format: 27.5.1 - '@testing-library/jest-dom@6.9.1': - dependencies: - '@adobe/css-tools': 4.5.0 - aria-query: 5.3.2 - css.escape: 1.5.1 - dom-accessibility-api: 0.6.3 - picocolors: 1.1.1 - redent: 3.0.0 - optional: true - '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)': dependencies: '@testing-library/dom': 10.4.1 @@ -15033,7 +14685,8 @@ snapshots: '@types/http-errors': 2.0.5 '@types/node': 24.12.4 - '@types/statuses@2.0.6': {} + '@types/statuses@2.0.6': + optional: true '@types/unist@2.0.11': {} @@ -15055,11 +14708,6 @@ snapshots: dependencies: '@types/yargs-parser': 21.0.3 - '@types/yauzl@2.10.3': - dependencies: - '@types/node': 24.12.4 - optional: true - '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260604.1': optional: true @@ -15338,6 +14986,8 @@ snapshots: agent-base@7.1.4: {} + agent-cli-detector@0.1.6: {} + agent-install@0.0.5: dependencies: '@iarna/toml': 2.2.5 @@ -15379,7 +15029,7 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 - alchemy@2.0.0-beta.65(75567d8add6e3362e26fe00f83a97bee): + alchemy@2.0.0-beta.65(00c448ade6580e73d10ccfe1b32cee97): dependencies: '@alchemy.run/node-utils': 0.0.5 '@aws-sdk/credential-providers': 3.1062.0 @@ -15424,7 +15074,7 @@ snapshots: '@effect/platform-node': 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@effect/sql-pg': 4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) drizzle-kit: 1.0.0-rc.4 - drizzle-orm: 1.0.0-rc.4(@cloudflare/workers-types@4.20260604.1)(@effect/sql-d1@4.0.0-beta.101(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)))(@effect/sql-pg@4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)))(@effect/sql-sqlite-bun@4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)))(@libsql/client@0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6))(bun-types@1.3.14)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(expo-sqlite@56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6))(mysql2@3.22.4(@types/node@24.12.4))(pg@8.21.0)(zod@4.4.3) + drizzle-orm: 1.0.0-rc.4(@cloudflare/workers-types@4.20260604.1)(@effect/sql-d1@4.0.0-beta.101(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)))(@effect/sql-pg@4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)))(@effect/sql-sqlite-bun@4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)))(@libsql/client@0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6))(bun-types@1.3.14)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(expo-sqlite@57.0.2(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6))(mysql2@3.22.4(@types/node@24.12.4))(pg@8.21.0)(zod@4.4.3) vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) transitivePeerDependencies: @@ -15722,70 +15372,21 @@ snapshots: babel-plugin-react-native-web@0.21.2: {} - babel-plugin-syntax-hermes-parser@0.33.3: + babel-plugin-syntax-hermes-parser@0.36.0: dependencies: - hermes-parser: 0.33.3 + hermes-parser: 0.36.0 - babel-plugin-transform-flow-enums@0.0.2(@babel/core@7.29.7): + babel-plugin-syntax-hermes-parser@0.36.1: dependencies: - '@babel/plugin-syntax-flow': 7.29.7(@babel/core@7.29.7) - transitivePeerDependencies: - - '@babel/core' + hermes-parser: 0.36.1 - babel-preset-expo@56.0.14(@babel/core@7.29.7)(@babel/runtime@7.29.7)(expo-widgets@56.0.19)(expo@56.0.12)(react-refresh@0.14.2): + babel-plugin-transform-flow-enums@0.0.2(@babel/core@7.29.7): dependencies: - '@babel/generator': 7.29.7 - '@babel/helper-module-imports': 7.29.7 - '@babel/plugin-proposal-decorators': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-proposal-export-default-from': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-syntax-dynamic-import': 7.8.3(@babel/core@7.29.7) - '@babel/plugin-syntax-export-default-from': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.29.7) - '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.29.7) - '@babel/plugin-transform-async-generator-functions': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-async-to-generator': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-block-scoping': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-class-properties': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-class-static-block': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-classes': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-destructuring': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-export-namespace-from': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-flow-strip-types': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-for-of': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-logical-assignment-operators': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-modules-commonjs': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-named-capturing-groups-regex': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-nullish-coalescing-operator': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-object-rest-spread': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-optional-catch-binding': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-optional-chaining': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-parameters': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-private-methods': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-private-property-in-object': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-react-display-name': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-react-jsx': 7.28.6(@babel/core@7.29.7) - '@babel/plugin-transform-react-jsx-development': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-react-pure-annotations': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-runtime': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-typescript': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-unicode-regex': 7.29.7(@babel/core@7.29.7) - '@babel/preset-typescript': 7.29.7(@babel/core@7.29.7) - '@react-native/babel-plugin-codegen': 0.85.3(@babel/core@7.29.7) - babel-plugin-react-compiler: 1.0.0 - babel-plugin-react-native-web: 0.21.2 - babel-plugin-syntax-hermes-parser: 0.33.3 - babel-plugin-transform-flow-enums: 0.0.2(@babel/core@7.29.7) - debug: 4.4.3 - react-refresh: 0.14.2 - optionalDependencies: - '@babel/runtime': 7.29.7 - expo: 56.0.12(8895228379997a2a064f9644cda56ed0) - expo-widgets: 56.0.19(3cdc0dde9f93166d952f1e1bd0cb25c0) + '@babel/plugin-syntax-flow': 7.29.7(@babel/core@7.29.7) transitivePeerDependencies: - '@babel/core' - - supports-color - babel-preset-expo@56.0.15(@babel/core@7.29.7)(@babel/runtime@7.29.7)(expo-widgets@56.0.19)(expo@56.0.12)(react-refresh@0.14.2): + babel-preset-expo@57.0.9(@babel/core@7.29.7)(@babel/runtime@7.29.7)(expo-widgets@57.0.15)(expo@57.0.18)(react-refresh@0.14.2): dependencies: '@babel/generator': 7.29.7 '@babel/helper-module-imports': 7.29.7 @@ -15823,17 +15424,17 @@ snapshots: '@babel/plugin-transform-typescript': 7.29.7(@babel/core@7.29.7) '@babel/plugin-transform-unicode-regex': 7.29.7(@babel/core@7.29.7) '@babel/preset-typescript': 7.29.7(@babel/core@7.29.7) - '@react-native/babel-plugin-codegen': 0.85.3(@babel/core@7.29.7) + '@react-native/babel-plugin-codegen': 0.86.3(@babel/core@7.29.7) babel-plugin-react-compiler: 1.0.0 babel-plugin-react-native-web: 0.21.2 - babel-plugin-syntax-hermes-parser: 0.33.3 + babel-plugin-syntax-hermes-parser: 0.36.1 babel-plugin-transform-flow-enums: 0.0.2(@babel/core@7.29.7) debug: 4.4.3 react-refresh: 0.14.2 optionalDependencies: '@babel/runtime': 7.29.7 - expo: 56.0.12(8895228379997a2a064f9644cda56ed0) - expo-widgets: 56.0.19(3cdc0dde9f93166d952f1e1bd0cb25c0) + expo: 57.0.18(f9c992a5d7c53d81398568d3950992dc) + expo-widgets: 57.0.15(@babel/core@7.29.7)(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(expo@57.0.18)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) transitivePeerDependencies: - '@babel/core' - supports-color @@ -15936,8 +15537,6 @@ snapshots: bson@6.10.4: {} - buffer-crc32@0.2.13: {} - buffer-from@1.1.2: {} bufferutil@4.1.0: @@ -16107,9 +15706,7 @@ snapshots: slice-ansi: 8.0.0 string-width: 8.2.1 - cli-width@4.1.0: {} - - client-only@0.0.1: + cli-width@4.1.0: optional: true cliui@8.0.1: @@ -16297,9 +15894,6 @@ snapshots: css-what@6.2.2: {} - css.escape@1.5.1: - optional: true - csso@5.0.5: dependencies: css-tree: 2.2.1 @@ -16408,9 +16002,6 @@ snapshots: dom-accessibility-api@0.5.16: {} - dom-accessibility-api@0.6.3: - optional: true - dom-serializer@2.0.0: dependencies: domelementtype: 2.3.0 @@ -16447,7 +16038,7 @@ snapshots: get-tsconfig: 4.14.0 jiti: 2.7.0 - drizzle-orm@1.0.0-rc.4(@cloudflare/workers-types@4.20260604.1)(@effect/sql-d1@4.0.0-beta.101(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)))(@effect/sql-pg@4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)))(@effect/sql-sqlite-bun@4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)))(@libsql/client@0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6))(bun-types@1.3.14)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(expo-sqlite@56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6))(mysql2@3.22.4(@types/node@24.12.4))(pg@8.21.0)(zod@4.4.3): + drizzle-orm@1.0.0-rc.4(@cloudflare/workers-types@4.20260604.1)(@effect/sql-d1@4.0.0-beta.101(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)))(@effect/sql-pg@4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)))(@effect/sql-sqlite-bun@4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)))(@libsql/client@0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6))(bun-types@1.3.14)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(expo-sqlite@57.0.2(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6))(mysql2@3.22.4(@types/node@24.12.4))(pg@8.21.0)(zod@4.4.3): optionalDependencies: '@cloudflare/workers-types': 4.20260604.1 '@effect/sql-d1': 4.0.0-beta.101(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) @@ -16456,7 +16047,7 @@ snapshots: '@libsql/client': 0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6) bun-types: 1.3.14 effect: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) - expo-sqlite: 56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) + expo-sqlite: 57.0.2(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) mysql2: 3.22.4(@types/node@24.12.4) pg: 8.21.0 zod: 4.4.3 @@ -16563,11 +16154,11 @@ snapshots: transitivePeerDependencies: - supports-color - electron@41.5.0: + electron@43.4.1: dependencies: - '@electron/get': 2.0.3 + '@electron-internal/extract-zip': 1.0.5 + '@electron/get': 5.1.0 '@types/node': 24.12.4 - extract-zip: 2.0.1 transitivePeerDependencies: - supports-color @@ -16588,6 +16179,11 @@ snapshots: dependencies: once: 1.4.0 + enhanced-resolve@5.21.6: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.3 + enhanced-resolve@5.22.1: dependencies: graceful-fs: 4.2.11 @@ -16599,6 +16195,8 @@ snapshots: env-paths@2.2.1: {} + env-paths@3.0.0: {} + environment@1.1.0: {} err-code@2.0.3: {} @@ -16723,228 +16321,226 @@ snapshots: expect-type@1.4.0: {} - expo-application@56.0.3(expo@56.0.12): + expo-application@57.0.2(expo@57.0.18): dependencies: - expo: 56.0.12(8895228379997a2a064f9644cda56ed0) + expo: 57.0.18(f9c992a5d7c53d81398568d3950992dc) - expo-asset@56.0.17(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3): + expo-asset@57.0.15(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3): dependencies: - '@expo/image-utils': 0.10.1(typescript@6.0.3) - expo: 56.0.12(8895228379997a2a064f9644cda56ed0) - expo-constants: 56.0.18(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + '@expo/image-utils': 0.11.5(typescript@6.0.3) + expo: 57.0.18(f9c992a5d7c53d81398568d3950992dc) + expo-constants: 57.0.16(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) transitivePeerDependencies: - supports-color - typescript - expo-asset@56.0.17(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)(typescript@6.0.3): + expo-asset@57.0.15(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)(typescript@6.0.3): dependencies: - '@expo/image-utils': 0.10.1(typescript@6.0.3) - expo: 56.0.12(deb8cbf3e0f411b34ba85a995c9982ba) - expo-constants: 56.0.18(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6)) + '@expo/image-utils': 0.11.5(typescript@6.0.3) + expo: 57.0.18(ecb3896f1dbe5154a21ddc585503f306) + expo-constants: 57.0.16(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6)) react: 19.2.6 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) + react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) transitivePeerDependencies: - supports-color - typescript optional: true - expo-auth-session@56.0.14(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + expo-audio@57.0.4(patch_hash=fa9a3e0442ed395d4071bb406e08c3a471c9a84700bdfa0b9ad7ff144c96041a)(expo-asset@57.0.15(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3))(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + dependencies: + expo: 57.0.18(f9c992a5d7c53d81398568d3950992dc) + expo-asset: 57.0.15(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3) + react: 19.2.3 + react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + + expo-auth-session@57.0.10(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: - expo-application: 56.0.3(expo@56.0.12) - expo-constants: 56.0.18(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) - expo-crypto: 56.0.4(expo@56.0.12) - expo-linking: 56.0.14(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - expo-web-browser: 56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + expo-application: 57.0.2(expo@57.0.18) + expo-constants: 57.0.16(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + expo-crypto: 57.0.2(expo@57.0.18) + expo-linking: 57.0.8(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + expo-web-browser: 57.0.2(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) invariant: 2.2.4 react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) transitivePeerDependencies: - expo - supports-color - expo-blur@56.0.3(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + expo-blur@57.0.2(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: - expo: 56.0.12(8895228379997a2a064f9644cda56ed0) + expo: 57.0.18(f9c992a5d7c53d81398568d3950992dc) react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - expo-build-properties@56.0.19(expo@56.0.12): + expo-build-properties@57.0.15(expo@57.0.18): dependencies: - '@expo/schema-utils': 56.0.1 - expo: 56.0.12(8895228379997a2a064f9644cda56ed0) + '@expo/schema-utils': 57.0.2 + expo: 57.0.18(f9c992a5d7c53d81398568d3950992dc) resolve-from: 5.0.0 semver: 7.8.5 - expo-camera@56.0.8(@types/emscripten@1.41.5)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + expo-camera@57.0.4(@types/emscripten@1.41.5)(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: barcode-detector: 3.2.0(@types/emscripten@1.41.5) - expo: 56.0.12(8895228379997a2a064f9644cda56ed0) + expo: 57.0.18(f9c992a5d7c53d81398568d3950992dc) react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) transitivePeerDependencies: - '@types/emscripten' - expo-clipboard@56.0.4(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + expo-clipboard@57.0.1(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: - expo: 56.0.12(8895228379997a2a064f9644cda56ed0) + expo: 57.0.18(f9c992a5d7c53d81398568d3950992dc) react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - expo-constants@56.0.18(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): + expo-constants@57.0.16(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): dependencies: - '@expo/env': 2.3.0 - expo: 56.0.12(8895228379997a2a064f9644cda56ed0) - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + '@expo/env': 2.4.3 + expo: 57.0.18(f9c992a5d7c53d81398568d3950992dc) + react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) transitivePeerDependencies: - supports-color - expo-constants@56.0.18(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6)): + expo-constants@57.0.16(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6)): dependencies: - '@expo/env': 2.3.0 - expo: 56.0.12(deb8cbf3e0f411b34ba85a995c9982ba) - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) + '@expo/env': 2.4.3 + expo: 57.0.18(ecb3896f1dbe5154a21ddc585503f306) + react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) transitivePeerDependencies: - supports-color optional: true - expo-crypto@56.0.4(expo@56.0.12): + expo-crypto@57.0.2(expo@57.0.18): dependencies: - expo: 56.0.12(8895228379997a2a064f9644cda56ed0) + expo: 57.0.18(f9c992a5d7c53d81398568d3950992dc) - expo-dev-client@56.0.20(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): + expo-dev-client@57.0.16(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): dependencies: - expo: 56.0.12(8895228379997a2a064f9644cda56ed0) - expo-dev-launcher: 56.0.20(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) - expo-dev-menu: 56.0.17(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) - expo-dev-menu-interface: 56.0.1(expo@56.0.12) - expo-manifests: 56.0.4(expo@56.0.12) - expo-updates-interface: 56.0.2(expo@56.0.12) + expo: 57.0.18(f9c992a5d7c53d81398568d3950992dc) + expo-dev-launcher: 57.0.16(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + expo-dev-menu: 57.0.16(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + expo-dev-menu-interface: 57.0.0(expo@57.0.18) + expo-manifests: 57.0.1(expo@57.0.18) + expo-updates-interface: 57.0.1(expo@57.0.18) transitivePeerDependencies: - react-native - expo-dev-launcher@56.0.20(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): + expo-dev-launcher@57.0.16(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): + dependencies: + '@expo/schema-utils': 57.0.2 + expo: 57.0.18(f9c992a5d7c53d81398568d3950992dc) + expo-dev-menu: 57.0.16(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + expo-manifests: 57.0.1(expo@57.0.18) + react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + + expo-dev-menu-interface@57.0.0(expo@57.0.18): dependencies: - '@expo/schema-utils': 56.0.1 - expo: 56.0.12(8895228379997a2a064f9644cda56ed0) - expo-dev-menu: 56.0.17(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) - expo-manifests: 56.0.4(expo@56.0.12) - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + expo: 57.0.18(f9c992a5d7c53d81398568d3950992dc) - expo-dev-menu-interface@56.0.1(expo@56.0.12): + expo-dev-menu@57.0.16(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): dependencies: - expo: 56.0.12(8895228379997a2a064f9644cda56ed0) + expo: 57.0.18(f9c992a5d7c53d81398568d3950992dc) + expo-dev-menu-interface: 57.0.0(expo@57.0.18) + react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - expo-dev-menu@56.0.17(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): + expo-device@57.0.1(expo@57.0.18): dependencies: - expo: 56.0.12(8895228379997a2a064f9644cda56ed0) - expo-dev-menu-interface: 56.0.1(expo@56.0.12) - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + expo: 57.0.18(f9c992a5d7c53d81398568d3950992dc) + ua-parser-js: 0.7.41 - expo-eas-client@56.0.1: {} + expo-document-picker@57.0.1(expo@57.0.18): + dependencies: + expo: 57.0.18(f9c992a5d7c53d81398568d3950992dc) + + expo-eas-client@57.0.2: {} - expo-file-system@56.0.8(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): + expo-file-system@57.0.6(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): dependencies: - expo: 56.0.12(8895228379997a2a064f9644cda56ed0) - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + expo: 57.0.18(f9c992a5d7c53d81398568d3950992dc) + react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - expo-file-system@56.0.8(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6)): + expo-file-system@57.0.6(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6)): dependencies: - expo: 56.0.12(deb8cbf3e0f411b34ba85a995c9982ba) - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) + expo: 57.0.18(ecb3896f1dbe5154a21ddc585503f306) + react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) optional: true - expo-font@56.0.7(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + expo-font@57.0.2(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: - expo: 56.0.12(8895228379997a2a064f9644cda56ed0) + expo: 57.0.18(f9c992a5d7c53d81398568d3950992dc) fontfaceobserver: 2.3.0 react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - expo-font@56.0.7(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6): + expo-font@57.0.2(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6): dependencies: - expo: 56.0.12(deb8cbf3e0f411b34ba85a995c9982ba) + expo: 57.0.18(ecb3896f1dbe5154a21ddc585503f306) fontfaceobserver: 2.3.0 react: 19.2.6 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) + react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) optional: true - expo-glass-effect@56.0.4(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + expo-glass-effect@57.0.1(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: - expo: 56.0.12(8895228379997a2a064f9644cda56ed0) + expo: 57.0.18(f9c992a5d7c53d81398568d3950992dc) react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - expo-glass-effect@56.0.4(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6): + expo-haptics@57.0.2(expo@57.0.18): dependencies: - expo: 56.0.12(deb8cbf3e0f411b34ba85a995c9982ba) - react: 19.2.6 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) - optional: true - - expo-haptics@56.0.3(expo@56.0.12): - dependencies: - expo: 56.0.12(8895228379997a2a064f9644cda56ed0) + expo: 57.0.18(f9c992a5d7c53d81398568d3950992dc) - expo-image-loader@56.0.3(expo@56.0.12): + expo-image-loader@57.0.1(expo@57.0.18): dependencies: - expo: 56.0.12(8895228379997a2a064f9644cda56ed0) + expo: 57.0.18(f9c992a5d7c53d81398568d3950992dc) - expo-image-picker@56.0.18(expo@56.0.12): + expo-image-picker@57.0.14(expo@57.0.18): dependencies: - expo: 56.0.12(8895228379997a2a064f9644cda56ed0) - expo-image-loader: 56.0.3(expo@56.0.12) + expo: 57.0.18(f9c992a5d7c53d81398568d3950992dc) + expo-image-loader: 57.0.1(expo@57.0.18) - expo-image@56.0.11(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + expo-image@57.0.3(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: - expo: 56.0.12(8895228379997a2a064f9644cda56ed0) + expo: 57.0.18(f9c992a5d7c53d81398568d3950992dc) react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) sf-symbols-typescript: 2.2.0 - expo-json-utils@56.0.0: {} + expo-json-utils@57.0.1: {} - expo-keep-awake@56.0.3(expo@56.0.12)(react@19.2.3): + expo-keep-awake@57.0.1(expo@57.0.18)(react@19.2.3): dependencies: - expo: 56.0.12(8895228379997a2a064f9644cda56ed0) + expo: 57.0.18(f9c992a5d7c53d81398568d3950992dc) react: 19.2.3 - expo-keep-awake@56.0.3(expo@56.0.12)(react@19.2.6): + expo-keep-awake@57.0.1(expo@57.0.18)(react@19.2.6): dependencies: - expo: 56.0.12(deb8cbf3e0f411b34ba85a995c9982ba) + expo: 57.0.18(ecb3896f1dbe5154a21ddc585503f306) react: 19.2.6 optional: true - expo-linking@56.0.14(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + expo-linking@57.0.8(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: - expo-constants: 56.0.18(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + expo-constants: 57.0.16(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) invariant: 2.2.4 react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) transitivePeerDependencies: - expo - supports-color - expo-linking@56.0.14(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6): - dependencies: - expo-constants: 56.0.18(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6)) - invariant: 2.2.4 - react: 19.2.6 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) - transitivePeerDependencies: - - expo - - supports-color - optional: true - - expo-manifests@56.0.4(expo@56.0.12): + expo-manifests@57.0.1(expo@57.0.18): dependencies: - expo: 56.0.12(8895228379997a2a064f9644cda56ed0) - expo-json-utils: 56.0.0 + expo: 57.0.18(f9c992a5d7c53d81398568d3950992dc) + expo-json-utils: 57.0.1 - expo-modules-autolinking@56.0.16(typescript@6.0.3): + expo-modules-autolinking@57.0.12(typescript@6.0.3): dependencies: - '@expo/require-utils': 56.1.3(typescript@6.0.3) + '@expo/require-utils': 57.0.5(typescript@6.0.3) '@expo/spawn-async': 1.8.0 chalk: 4.1.2 commander: 7.2.0 @@ -16952,317 +16548,210 @@ snapshots: - supports-color - typescript - expo-modules-core@56.0.17(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + expo-modules-core@57.0.14(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: - '@expo/expo-modules-macros-plugin': 0.2.2 - expo-modules-jsi: 56.0.10(patch_hash=9170f8074ae4e35a0a086e756c8f815794fd3abe51eac67ca3ba02804225ec1f)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + '@expo/expo-modules-macros-plugin': 0.6.1 + expo-modules-jsi: 57.0.6(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) invariant: 2.2.4 react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) optionalDependencies: - react-native-worklets: 0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native-worklets: 0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - expo-modules-core@56.0.17(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6): + expo-modules-core@57.0.14(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6): dependencies: - '@expo/expo-modules-macros-plugin': 0.2.2 - expo-modules-jsi: 56.0.10(patch_hash=9170f8074ae4e35a0a086e756c8f815794fd3abe51eac67ca3ba02804225ec1f)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6)) + '@expo/expo-modules-macros-plugin': 0.6.1 + expo-modules-jsi: 57.0.6(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6)) invariant: 2.2.4 react: 19.2.6 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) + react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) optionalDependencies: - react-native-worklets: 0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) + react-native-worklets: 0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) optional: true - expo-modules-jsi@56.0.10(patch_hash=9170f8074ae4e35a0a086e756c8f815794fd3abe51eac67ca3ba02804225ec1f)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): + expo-modules-jsi@57.0.6(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): dependencies: - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - expo-modules-jsi@56.0.10(patch_hash=9170f8074ae4e35a0a086e756c8f815794fd3abe51eac67ca3ba02804225ec1f)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6)): + expo-modules-jsi@57.0.6(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6)): dependencies: - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) + react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) optional: true - expo-network@56.0.5(expo@56.0.12)(react@19.2.3): + expo-network@57.0.1(expo@57.0.18)(react@19.2.3): dependencies: - expo: 56.0.12(8895228379997a2a064f9644cda56ed0) + expo: 57.0.18(f9c992a5d7c53d81398568d3950992dc) react: 19.2.3 - expo-notifications@56.0.18(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3): + expo-notifications@57.0.15(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3): dependencies: - '@expo/image-utils': 0.10.1(typescript@6.0.3) + '@expo/image-utils': 0.11.5(typescript@6.0.3) abort-controller: 3.0.0 badgin: 1.2.3 - expo: 56.0.12(8895228379997a2a064f9644cda56ed0) - expo-application: 56.0.3(expo@56.0.12) - expo-constants: 56.0.18(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + expo: 57.0.18(f9c992a5d7c53d81398568d3950992dc) + expo-application: 57.0.2(expo@57.0.18) + expo-constants: 57.0.16(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) transitivePeerDependencies: - supports-color - typescript - expo-paste-input@0.1.15(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + expo-paste-input@0.1.15(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: - expo: 56.0.12(8895228379997a2a064f9644cda56ed0) + expo: 57.0.18(f9c992a5d7c53d81398568d3950992dc) react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - expo-quick-actions@6.0.2(expo@56.0.12)(typescript@6.0.3): + expo-quick-actions@6.0.2(expo@57.0.18)(typescript@6.0.3): dependencies: '@expo/image-utils': 0.8.14(typescript@6.0.3) - expo: 56.0.12(8895228379997a2a064f9644cda56ed0) + expo: 57.0.18(f9c992a5d7c53d81398568d3950992dc) schema-utils: 4.3.3 sf-symbols-typescript: 2.2.0 transitivePeerDependencies: - supports-color - typescript - expo-router@56.2.11(80beea6a31a5d2003a696c1401258797): + expo-secure-store@57.0.2(expo@57.0.18): dependencies: - '@expo/log-box': 56.0.13(@expo/dom-webview@56.0.5)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) - '@expo/metro-runtime': 56.0.15(@expo/log-box@56.0.13)(expo@56.0.12)(react-dom@19.2.6(react@19.2.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) - '@expo/schema-utils': 56.0.1 - '@expo/ui': 56.0.18(32843e0c0883df8bccfa0b8323659df5) - '@radix-ui/react-slot': 1.2.4(@types/react@19.2.16)(react@19.2.6) - '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@react-native-masked-view/masked-view': 0.3.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) - '@testing-library/jest-dom': 6.9.1 - '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.1) - client-only: 0.0.1 - color: 4.2.3 - debug: 4.4.3 - escape-string-regexp: 4.0.0 - expo: 56.0.12(deb8cbf3e0f411b34ba85a995c9982ba) - expo-constants: 56.0.18(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6)) - expo-glass-effect: 56.0.4(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) - expo-linking: 56.0.14(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) - expo-server: 56.0.5 - expo-symbols: 56.0.6(expo-font@56.0.7)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) - fast-deep-equal: 3.1.3 - invariant: 2.2.4 - nanoid: 3.3.12 - query-string: 7.1.3 - react: 19.2.6 - react-fast-compare: 3.2.2 - react-is: 19.2.7 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) - react-native-drawer-layout: 4.2.4(de9b2f2dc96a3557fdc0df187a8417ee) - react-native-safe-area-context: 5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) - react-native-screens: 4.25.2(patch_hash=59bfd7b84af01708b6e581c4ccdd5ecf05f8b205383802d66b64ed7ea7bb2199)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) - server-only: 0.0.1 - sf-symbols-typescript: 2.2.0 - shallowequal: 1.1.0 - standard-navigation: 0.0.5 - vaul: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - optionalDependencies: - react-dom: 19.2.6(react@19.2.6) - react-native-gesture-handler: 2.31.2(patch_hash=808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d3)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) - react-native-reanimated: 4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) - transitivePeerDependencies: - - '@babel/core' - - '@testing-library/dom' - - '@types/react' - - '@types/react-dom' - - expo-font - - react-native-worklets - - supports-color - optional: true + expo: 57.0.18(f9c992a5d7c53d81398568d3950992dc) - expo-router@56.2.11(e1497a99e5bc5be76c1cdb733671f865): - dependencies: - '@expo/log-box': 56.0.13(@expo/dom-webview@56.0.5)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - '@expo/metro-runtime': 56.0.15(@expo/log-box@56.0.13)(expo@56.0.12)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - '@expo/schema-utils': 56.0.1 - '@expo/ui': 56.0.18(3cdc0dde9f93166d952f1e1bd0cb25c0) - '@radix-ui/react-slot': 1.2.4(@types/react@19.2.16)(react@19.2.3) - '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@react-native-masked-view/masked-view': 0.3.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - '@testing-library/jest-dom': 6.9.1 - '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.1) - client-only: 0.0.1 - color: 4.2.3 - debug: 4.4.3 - escape-string-regexp: 4.0.0 - expo: 56.0.12(8895228379997a2a064f9644cda56ed0) - expo-constants: 56.0.18(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) - expo-glass-effect: 56.0.4(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - expo-linking: 56.0.14(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - expo-server: 56.0.5 - expo-symbols: 56.0.6(expo-font@56.0.7)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - fast-deep-equal: 3.1.3 - invariant: 2.2.4 - nanoid: 3.3.12 - query-string: 7.1.3 - react: 19.2.3 - react-fast-compare: 3.2.2 - react-is: 19.2.7 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - react-native-drawer-layout: 4.2.4(05364bd849de538917a7364cc7dee3f5) - react-native-safe-area-context: 5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - react-native-screens: 4.25.2(patch_hash=59bfd7b84af01708b6e581c4ccdd5ecf05f8b205383802d66b64ed7ea7bb2199)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - server-only: 0.0.1 - sf-symbols-typescript: 2.2.0 - shallowequal: 1.1.0 - standard-navigation: 0.0.5 - vaul: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - optionalDependencies: - react-dom: 19.2.3(react@19.2.3) - react-native-gesture-handler: 2.31.2(patch_hash=808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d3)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - react-native-reanimated: 4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - transitivePeerDependencies: - - '@babel/core' - - '@testing-library/dom' - - '@types/react' - - '@types/react-dom' - - expo-font - - react-native-worklets - - supports-color - optional: true + expo-server@57.0.3: {} - expo-secure-store@56.0.4(expo@56.0.12): + expo-sharing@57.0.16(patch_hash=8d2e3b10eb3f52036a9a086800180ec6cebf3b75bccc5b1775117a7244d4ac45)(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3): dependencies: - expo: 56.0.12(8895228379997a2a064f9644cda56ed0) - - expo-server@56.0.5: {} - - expo-sharing@56.0.18(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3): - dependencies: - '@expo/config-plugins': 56.0.9(typescript@6.0.3) - '@expo/config-types': 56.0.6 - '@expo/plist': 0.7.0 - expo: 56.0.12(8895228379997a2a064f9644cda56ed0) + '@expo/config-plugins': 57.0.9(typescript@6.0.3) + '@expo/config-types': 57.0.2 + '@expo/plist': 0.8.1 + expo: 57.0.18(f9c992a5d7c53d81398568d3950992dc) react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) transitivePeerDependencies: - supports-color - typescript - expo-splash-screen@56.0.10(expo@56.0.12)(typescript@6.0.3): + expo-splash-screen@57.0.8(expo@57.0.18)(typescript@6.0.3): dependencies: - '@expo/config-plugins': 56.0.9(typescript@6.0.3) - '@expo/image-utils': 0.10.1(typescript@6.0.3) - expo: 56.0.12(8895228379997a2a064f9644cda56ed0) + '@expo/config-plugins': 57.0.9(typescript@6.0.3) + '@expo/image-utils': 0.11.5(typescript@6.0.3) + expo: 57.0.18(f9c992a5d7c53d81398568d3950992dc) xml2js: 0.6.0 transitivePeerDependencies: - supports-color - typescript - expo-sqlite@56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + expo-sqlite@57.0.2(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: await-lock: 2.2.2 - expo: 56.0.12(8895228379997a2a064f9644cda56ed0) + expo: 57.0.18(f9c992a5d7c53d81398568d3950992dc) react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - expo-sqlite@56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6): + expo-sqlite@57.0.2(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6): dependencies: await-lock: 2.2.2 - expo: 56.0.12(deb8cbf3e0f411b34ba85a995c9982ba) + expo: 57.0.18(ecb3896f1dbe5154a21ddc585503f306) react: 19.2.6 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) + react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) optional: true - expo-structured-headers@56.0.0: {} + expo-structured-headers@57.0.0: {} - expo-symbols@56.0.6(expo-font@56.0.7)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + expo-symbols@57.0.2(expo-font@57.0.2(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: '@expo-google-fonts/material-symbols': 0.4.38 - expo: 56.0.12(8895228379997a2a064f9644cda56ed0) - expo-font: 56.0.7(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + expo: 57.0.18(f9c992a5d7c53d81398568d3950992dc) + expo-font: 57.0.2(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) sf-symbols-typescript: 2.2.0 - expo-symbols@56.0.6(expo-font@56.0.7)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6): + expo-updates-interface@57.0.1(expo@57.0.18): dependencies: - '@expo-google-fonts/material-symbols': 0.4.38 - expo: 56.0.12(deb8cbf3e0f411b34ba85a995c9982ba) - expo-font: 56.0.7(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) - react: 19.2.6 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) - sf-symbols-typescript: 2.2.0 - optional: true + expo: 57.0.18(f9c992a5d7c53d81398568d3950992dc) - expo-updates-interface@56.0.2(expo@56.0.12): - dependencies: - expo: 56.0.12(8895228379997a2a064f9644cda56ed0) - - expo-updates@56.0.19(expo-dev-client@56.0.20(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + expo-updates@57.0.19(expo-dev-client@57.0.16(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: '@expo/code-signing-certificates': 0.0.6 - '@expo/plist': 0.7.0 + '@expo/plist': 0.8.1 '@expo/spawn-async': 1.8.0 arg: 4.1.3 chalk: 4.1.2 debug: 4.4.3 - expo: 56.0.12(8895228379997a2a064f9644cda56ed0) - expo-eas-client: 56.0.1 - expo-manifests: 56.0.4(expo@56.0.12) - expo-structured-headers: 56.0.0 - expo-updates-interface: 56.0.2(expo@56.0.12) + expo: 57.0.18(f9c992a5d7c53d81398568d3950992dc) + expo-eas-client: 57.0.2 + expo-manifests: 57.0.1(expo@57.0.18) + expo-structured-headers: 57.0.0 + expo-updates-interface: 57.0.1(expo@57.0.18) getenv: 2.0.0 glob: 13.0.6 ignore: 5.3.2 nullthrows: 1.1.1 react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) resolve-from: 5.0.0 optionalDependencies: - expo-dev-client: 56.0.20(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + expo-dev-client: 57.0.16(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) transitivePeerDependencies: - supports-color - expo-web-browser@56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): + expo-video@57.0.3(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: - expo: 56.0.12(8895228379997a2a064f9644cda56ed0) - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + expo: 57.0.18(f9c992a5d7c53d81398568d3950992dc) + react: 19.2.3 + react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - expo-widgets@56.0.19(3cdc0dde9f93166d952f1e1bd0cb25c0): + expo-web-browser@57.0.2(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): dependencies: - '@expo/plist': 0.7.0 - '@expo/ui': 56.0.18(3cdc0dde9f93166d952f1e1bd0cb25c0) - expo: 56.0.12(8895228379997a2a064f9644cda56ed0) + expo: 57.0.18(f9c992a5d7c53d81398568d3950992dc) + react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + + expo-widgets@57.0.15(@babel/core@7.29.7)(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(expo@57.0.18)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + dependencies: + '@expo/plist': 0.8.1 + '@expo/ui': 57.0.14(@babel/core@7.29.7)(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(expo@57.0.18)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + expo: 57.0.18(f9c992a5d7c53d81398568d3950992dc) react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) transitivePeerDependencies: - '@babel/core' - '@types/react' - '@types/react-dom' - react-dom - - react-native-reanimated - react-native-worklets - expo@56.0.12(8895228379997a2a064f9644cda56ed0): + expo@57.0.18(ecb3896f1dbe5154a21ddc585503f306): dependencies: '@babel/runtime': 7.29.7 - '@expo/cli': 56.1.16(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-constants@56.0.18)(expo-font@56.0.7)(expo-router@56.2.11)(expo@56.0.12)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) - '@expo/config': 56.0.9(typescript@6.0.3) - '@expo/config-plugins': 56.0.9(typescript@6.0.3) - '@expo/devtools': 56.0.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - '@expo/fingerprint': 0.19.4 - '@expo/local-build-cache-provider': 56.0.8(typescript@6.0.3) - '@expo/log-box': 56.0.13(@expo/dom-webview@56.0.5)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - '@expo/metro': 56.0.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) - '@expo/metro-config': 56.0.14(patch_hash=8cb08b5bb7051ed9d2dbe46a2c293c5a1e17f1bd6ddf30de27909e18c921ff46)(bufferutil@4.1.0)(expo@56.0.12)(typescript@6.0.3)(utf-8-validate@6.0.6) + '@expo/cli': 57.0.20(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.14)(bufferutil@4.1.0)(expo-constants@57.0.16(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6)))(expo-font@57.0.2(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6))(expo@57.0.18)(react-dom@19.2.6(react@19.2.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)(typescript@6.0.3)(utf-8-validate@6.0.6) + '@expo/config': 57.0.9(typescript@6.0.3) + '@expo/config-plugins': 57.0.9(typescript@6.0.3) + '@expo/devtools': 57.0.1(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) + '@expo/fingerprint': 0.20.11 + '@expo/local-build-cache-provider': 57.0.8(typescript@6.0.3) + '@expo/log-box': 57.0.4(@expo/dom-webview@57.0.1)(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) + '@expo/metro': 56.0.2(bufferutil@4.1.0)(utf-8-validate@6.0.6) + '@expo/metro-config': 57.0.12(patch_hash=96f1a75347e6ea02dc4b7034ace815d8ee39e18b8166ebfb573d9e58328f0dc2)(bufferutil@4.1.0)(expo@57.0.18)(typescript@6.0.3)(utf-8-validate@6.0.6) '@ungap/structured-clone': 1.3.1 - babel-preset-expo: 56.0.15(@babel/core@7.29.7)(@babel/runtime@7.29.7)(expo-widgets@56.0.19)(expo@56.0.12)(react-refresh@0.14.2) - expo-asset: 56.0.17(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3) - expo-constants: 56.0.18(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) - expo-file-system: 56.0.8(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) - expo-font: 56.0.7(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - expo-keep-awake: 56.0.3(expo@56.0.12)(react@19.2.3) - expo-modules-autolinking: 56.0.16(typescript@6.0.3) - expo-modules-core: 56.0.17(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + babel-preset-expo: 57.0.9(@babel/core@7.29.7)(@babel/runtime@7.29.7)(expo-widgets@57.0.15)(expo@57.0.18)(react-refresh@0.14.2) + expo-asset: 57.0.15(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)(typescript@6.0.3) + expo-constants: 57.0.16(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6)) + expo-file-system: 57.0.6(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6)) + expo-font: 57.0.2(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) + expo-keep-awake: 57.0.1(expo@57.0.18)(react@19.2.6) + expo-modules-autolinking: 57.0.12(typescript@6.0.3) + expo-modules-core: 57.0.14(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) pretty-format: 29.7.0 - react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react: 19.2.6 + react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) react-refresh: 0.14.2 whatwg-url-minimum: 0.1.2 optionalDependencies: - '@expo/dom-webview': 56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - '@expo/metro-runtime': 56.0.15(@expo/log-box@56.0.13)(expo@56.0.12)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - react-dom: 19.2.3(react@19.2.3) - react-native-webview: 13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + '@expo/dom-webview': 57.0.1(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) + '@expo/metro-runtime': 57.0.14(@expo/log-box@57.0.4)(expo@57.0.18)(react-dom@19.2.6(react@19.2.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) + react-dom: 19.2.6(react@19.2.6) + react-native-webview: 13.16.1(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) transitivePeerDependencies: - '@babel/core' - bufferutil @@ -17273,38 +16762,39 @@ snapshots: - supports-color - typescript - utf-8-validate + optional: true - expo@56.0.12(deb8cbf3e0f411b34ba85a995c9982ba): + expo@57.0.18(f9c992a5d7c53d81398568d3950992dc): dependencies: '@babel/runtime': 7.29.7 - '@expo/cli': 56.1.16(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-constants@56.0.18)(expo-font@56.0.7)(expo-router@56.2.11)(expo@56.0.12)(react-dom@19.2.6(react@19.2.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)(typescript@6.0.3)(utf-8-validate@6.0.6) - '@expo/config': 56.0.9(typescript@6.0.3) - '@expo/config-plugins': 56.0.9(typescript@6.0.3) - '@expo/devtools': 56.0.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) - '@expo/fingerprint': 0.19.4 - '@expo/local-build-cache-provider': 56.0.8(typescript@6.0.3) - '@expo/log-box': 56.0.13(@expo/dom-webview@56.0.5)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) - '@expo/metro': 56.0.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) - '@expo/metro-config': 56.0.14(patch_hash=8cb08b5bb7051ed9d2dbe46a2c293c5a1e17f1bd6ddf30de27909e18c921ff46)(bufferutil@4.1.0)(expo@56.0.12)(typescript@6.0.3)(utf-8-validate@6.0.6) + '@expo/cli': 57.0.20(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.14)(bufferutil@4.1.0)(expo-constants@57.0.16(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo-font@57.0.2(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(expo@57.0.18)(react-dom@19.2.3(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + '@expo/config': 57.0.9(typescript@6.0.3) + '@expo/config-plugins': 57.0.9(typescript@6.0.3) + '@expo/devtools': 57.0.1(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + '@expo/fingerprint': 0.20.11 + '@expo/local-build-cache-provider': 57.0.8(typescript@6.0.3) + '@expo/log-box': 57.0.4(@expo/dom-webview@57.0.1)(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + '@expo/metro': 56.0.2(bufferutil@4.1.0)(utf-8-validate@6.0.6) + '@expo/metro-config': 57.0.12(patch_hash=96f1a75347e6ea02dc4b7034ace815d8ee39e18b8166ebfb573d9e58328f0dc2)(bufferutil@4.1.0)(expo@57.0.18)(typescript@6.0.3)(utf-8-validate@6.0.6) '@ungap/structured-clone': 1.3.1 - babel-preset-expo: 56.0.15(@babel/core@7.29.7)(@babel/runtime@7.29.7)(expo-widgets@56.0.19)(expo@56.0.12)(react-refresh@0.14.2) - expo-asset: 56.0.17(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)(typescript@6.0.3) - expo-constants: 56.0.18(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6)) - expo-file-system: 56.0.8(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6)) - expo-font: 56.0.7(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) - expo-keep-awake: 56.0.3(expo@56.0.12)(react@19.2.6) - expo-modules-autolinking: 56.0.16(typescript@6.0.3) - expo-modules-core: 56.0.17(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) + babel-preset-expo: 57.0.9(@babel/core@7.29.7)(@babel/runtime@7.29.7)(expo-widgets@57.0.15)(expo@57.0.18)(react-refresh@0.14.2) + expo-asset: 57.0.15(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3) + expo-constants: 57.0.16(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + expo-file-system: 57.0.6(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + expo-font: 57.0.2(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + expo-keep-awake: 57.0.1(expo@57.0.18)(react@19.2.3) + expo-modules-autolinking: 57.0.12(typescript@6.0.3) + expo-modules-core: 57.0.14(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) pretty-format: 29.7.0 - react: 19.2.6 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) + react: 19.2.3 + react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) react-refresh: 0.14.2 whatwg-url-minimum: 0.1.2 optionalDependencies: - '@expo/dom-webview': 56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) - '@expo/metro-runtime': 56.0.15(@expo/log-box@56.0.13)(expo@56.0.12)(react-dom@19.2.6(react@19.2.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) - react-dom: 19.2.6(react@19.2.6) - react-native-webview: 13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) + '@expo/dom-webview': 57.0.1(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + '@expo/metro-runtime': 57.0.14(@expo/log-box@57.0.4)(expo@57.0.18)(react-dom@19.2.3(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-dom: 19.2.3(react@19.2.3) + react-native-webview: 13.16.1(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) transitivePeerDependencies: - '@babel/core' - bufferutil @@ -17315,7 +16805,6 @@ snapshots: - supports-color - typescript - utf-8-validate - optional: true exponential-backoff@3.1.3: {} @@ -17359,16 +16848,6 @@ snapshots: extend@3.0.2: {} - extract-zip@2.0.1: - dependencies: - debug: 4.4.3 - get-stream: 5.2.0 - yauzl: 2.10.0 - optionalDependencies: - '@types/yauzl': 2.10.3 - transitivePeerDependencies: - - supports-color - fast-check@4.9.0: dependencies: pure-rand: 8.4.0 @@ -17434,10 +16913,6 @@ snapshots: dependencies: bser: 2.1.1 - fd-slicer@1.1.0: - dependencies: - pend: 1.2.0 - fdir@6.5.0(picomatch@4.0.4): optionalDependencies: picomatch: 4.0.4 @@ -17670,7 +17145,8 @@ snapshots: graceful-fs@4.2.11: {} - graphql@16.14.1: {} + graphql@16.14.1: + optional: true h3@1.15.11: dependencies: @@ -17818,22 +17294,31 @@ snapshots: property-information: 7.2.0 space-separated-tokens: 2.0.2 - headers-polyfill@4.0.3: {} + headers-polyfill@4.0.3: + optional: true - hermes-compiler@250829098.0.10: {} + heic-to@1.5.2: {} - hermes-estree@0.33.3: {} + hermes-compiler@250829098.0.17: {} hermes-estree@0.35.0: {} - hermes-parser@0.33.3: - dependencies: - hermes-estree: 0.33.3 + hermes-estree@0.36.0: {} + + hermes-estree@0.36.1: {} hermes-parser@0.35.0: dependencies: hermes-estree: 0.35.0 + hermes-parser@0.36.0: + dependencies: + hermes-estree: 0.36.0 + + hermes-parser@0.36.1: + dependencies: + hermes-estree: 0.36.1 + hoist-non-react-statics@3.3.2: dependencies: react-is: 16.13.1 @@ -17902,9 +17387,6 @@ snapshots: immediate@3.0.6: {} - indent-string@4.0.0: - optional: true - indent-string@5.0.0: {} inflight@1.0.6: @@ -18018,7 +17500,8 @@ snapshots: is-interactive@2.0.0: {} - is-node-process@1.2.0: {} + is-node-process@1.2.0: + optional: true is-number@7.0.0: {} @@ -18129,6 +17612,8 @@ snapshots: json-schema-typed@8.0.2: {} + json-schema@0.4.0: {} + json-stringify-safe@5.0.1: optional: true @@ -18211,99 +17696,66 @@ snapshots: transitivePeerDependencies: - supports-color - lightningcss-android-arm64@1.31.1: - optional: true - lightningcss-android-arm64@1.32.0: optional: true lightningcss-darwin-arm64@1.30.1: optional: true - lightningcss-darwin-arm64@1.31.1: - optional: true - lightningcss-darwin-arm64@1.32.0: optional: true lightningcss-darwin-x64@1.30.1: optional: true - lightningcss-darwin-x64@1.31.1: - optional: true - lightningcss-darwin-x64@1.32.0: optional: true lightningcss-freebsd-x64@1.30.1: optional: true - lightningcss-freebsd-x64@1.31.1: - optional: true - lightningcss-freebsd-x64@1.32.0: optional: true lightningcss-linux-arm-gnueabihf@1.30.1: optional: true - lightningcss-linux-arm-gnueabihf@1.31.1: - optional: true - lightningcss-linux-arm-gnueabihf@1.32.0: optional: true lightningcss-linux-arm64-gnu@1.30.1: optional: true - lightningcss-linux-arm64-gnu@1.31.1: - optional: true - lightningcss-linux-arm64-gnu@1.32.0: optional: true lightningcss-linux-arm64-musl@1.30.1: optional: true - lightningcss-linux-arm64-musl@1.31.1: - optional: true - lightningcss-linux-arm64-musl@1.32.0: optional: true lightningcss-linux-x64-gnu@1.30.1: optional: true - lightningcss-linux-x64-gnu@1.31.1: - optional: true - lightningcss-linux-x64-gnu@1.32.0: optional: true lightningcss-linux-x64-musl@1.30.1: optional: true - lightningcss-linux-x64-musl@1.31.1: - optional: true - lightningcss-linux-x64-musl@1.32.0: optional: true lightningcss-win32-arm64-msvc@1.30.1: optional: true - lightningcss-win32-arm64-msvc@1.31.1: - optional: true - lightningcss-win32-arm64-msvc@1.32.0: optional: true lightningcss-win32-x64-msvc@1.30.1: optional: true - lightningcss-win32-x64-msvc@1.31.1: - optional: true - lightningcss-win32-x64-msvc@1.32.0: optional: true @@ -18322,22 +17774,6 @@ snapshots: lightningcss-win32-arm64-msvc: 1.30.1 lightningcss-win32-x64-msvc: 1.30.1 - lightningcss@1.31.1: - dependencies: - detect-libc: 2.1.2 - optionalDependencies: - lightningcss-android-arm64: 1.31.1 - lightningcss-darwin-arm64: 1.31.1 - lightningcss-darwin-x64: 1.31.1 - lightningcss-freebsd-x64: 1.31.1 - lightningcss-linux-arm-gnueabihf: 1.31.1 - lightningcss-linux-arm64-gnu: 1.31.1 - lightningcss-linux-arm64-musl: 1.31.1 - lightningcss-linux-x64-gnu: 1.31.1 - lightningcss-linux-x64-musl: 1.31.1 - lightningcss-win32-arm64-msvc: 1.31.1 - lightningcss-win32-x64-msvc: 1.31.1 - lightningcss@1.32.0: dependencies: detect-libc: 2.1.2 @@ -18442,6 +17878,20 @@ snapshots: unist-util-visit: 5.1.0 optional: true + mdast-util-directive@3.1.0: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + parse-entities: 4.0.2 + stringify-entities: 4.0.4 + unist-util-visit-parents: 6.0.2 + transitivePeerDependencies: + - supports-color + mdast-util-find-and-replace@3.0.2: dependencies: '@types/mdast': 4.0.4 @@ -18628,10 +18078,24 @@ snapshots: transitivePeerDependencies: - supports-color + metro-babel-transformer@0.84.5: + dependencies: + '@babel/core': 7.29.7 + flow-enums-runtime: 0.0.6 + hermes-parser: 0.35.0 + metro-cache-key: 0.84.5 + nullthrows: 1.1.1 + transitivePeerDependencies: + - supports-color + metro-cache-key@0.84.4: dependencies: flow-enums-runtime: 0.0.6 + metro-cache-key@0.84.5: + dependencies: + flow-enums-runtime: 0.0.6 + metro-cache@0.84.4: dependencies: exponential-backoff: 3.1.3 @@ -18641,15 +18105,39 @@ snapshots: transitivePeerDependencies: - supports-color - metro-config@0.84.4(bufferutil@4.1.0)(utf-8-validate@6.0.6): + metro-cache@0.84.5: + dependencies: + exponential-backoff: 3.1.3 + flow-enums-runtime: 0.0.6 + https-proxy-agent: 7.0.6 + metro-core: 0.84.5 + transitivePeerDependencies: + - supports-color + + metro-config@0.84.4(bufferutil@4.1.0)(utf-8-validate@6.0.6): + dependencies: + connect: 3.7.0 + flow-enums-runtime: 0.0.6 + jest-validate: 29.7.0 + metro: 0.84.4(bufferutil@4.1.0)(utf-8-validate@6.0.6) + metro-cache: 0.84.4 + metro-core: 0.84.4 + metro-runtime: 0.84.4 + yaml: 2.9.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + metro-config@0.84.5(bufferutil@4.1.0)(utf-8-validate@6.0.6): dependencies: connect: 3.7.0 flow-enums-runtime: 0.0.6 jest-validate: 29.7.0 - metro: 0.84.4(bufferutil@4.1.0)(utf-8-validate@6.0.6) - metro-cache: 0.84.4 - metro-core: 0.84.4 - metro-runtime: 0.84.4 + metro: 0.84.5(bufferutil@4.1.0)(utf-8-validate@6.0.6) + metro-cache: 0.84.5 + metro-core: 0.84.5 + metro-runtime: 0.84.5 yaml: 2.9.0 transitivePeerDependencies: - bufferutil @@ -18662,6 +18150,12 @@ snapshots: lodash.throttle: 4.1.1 metro-resolver: 0.84.4 + metro-core@0.84.5: + dependencies: + flow-enums-runtime: 0.0.6 + lodash.throttle: 4.1.1 + metro-resolver: 0.84.5 + metro-file-map@0.84.4: dependencies: debug: 4.4.3 @@ -18676,20 +18170,48 @@ snapshots: transitivePeerDependencies: - supports-color + metro-file-map@0.84.5: + dependencies: + debug: 4.4.3 + fb-watchman: 2.0.2 + flow-enums-runtime: 0.0.6 + graceful-fs: 4.2.11 + invariant: 2.2.4 + jest-worker: 29.7.0 + micromatch: 4.0.8 + nullthrows: 1.1.1 + walker: 1.0.8 + transitivePeerDependencies: + - supports-color + metro-minify-terser@0.84.4: dependencies: flow-enums-runtime: 0.0.6 terser: 5.48.0 + metro-minify-terser@0.84.5: + dependencies: + flow-enums-runtime: 0.0.6 + terser: 5.48.0 + metro-resolver@0.84.4: dependencies: flow-enums-runtime: 0.0.6 + metro-resolver@0.84.5: + dependencies: + flow-enums-runtime: 0.0.6 + metro-runtime@0.84.4: dependencies: '@babel/runtime': 7.29.7 flow-enums-runtime: 0.0.6 + metro-runtime@0.84.5: + dependencies: + '@babel/runtime': 7.29.7 + flow-enums-runtime: 0.0.6 + metro-source-map@0.84.4: dependencies: '@babel/traverse': 7.29.7 @@ -18704,6 +18226,20 @@ snapshots: transitivePeerDependencies: - supports-color + metro-source-map@0.84.5: + dependencies: + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + flow-enums-runtime: 0.0.6 + invariant: 2.2.4 + metro-symbolicate: 0.84.5 + nullthrows: 1.1.1 + ob1: 0.84.5 + source-map: 0.5.7 + vlq: 1.0.1 + transitivePeerDependencies: + - supports-color + metro-symbolicate@0.84.4: dependencies: flow-enums-runtime: 0.0.6 @@ -18715,6 +18251,17 @@ snapshots: transitivePeerDependencies: - supports-color + metro-symbolicate@0.84.5: + dependencies: + flow-enums-runtime: 0.0.6 + invariant: 2.2.4 + metro-source-map: 0.84.5 + nullthrows: 1.1.1 + source-map: 0.5.7 + vlq: 1.0.1 + transitivePeerDependencies: + - supports-color + metro-transform-plugins@0.84.4: dependencies: '@babel/core': 7.29.7 @@ -18726,6 +18273,17 @@ snapshots: transitivePeerDependencies: - supports-color + metro-transform-plugins@0.84.5: + dependencies: + '@babel/core': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.7 + flow-enums-runtime: 0.0.6 + nullthrows: 1.1.1 + transitivePeerDependencies: + - supports-color + metro-transform-worker@0.84.4(bufferutil@4.1.0)(utf-8-validate@6.0.6): dependencies: '@babel/core': 7.29.7 @@ -18746,6 +18304,26 @@ snapshots: - supports-color - utf-8-validate + metro-transform-worker@0.84.5(bufferutil@4.1.0)(utf-8-validate@6.0.6): + dependencies: + '@babel/core': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + flow-enums-runtime: 0.0.6 + metro: 0.84.5(bufferutil@4.1.0)(utf-8-validate@6.0.6) + metro-babel-transformer: 0.84.5 + metro-cache: 0.84.5 + metro-cache-key: 0.84.5 + metro-minify-terser: 0.84.5 + metro-source-map: 0.84.5 + metro-transform-plugins: 0.84.5 + nullthrows: 1.1.1 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + metro@0.84.4(bufferutil@4.1.0)(utf-8-validate@6.0.6): dependencies: '@babel/code-frame': 7.29.7 @@ -18792,6 +18370,51 @@ snapshots: - supports-color - utf-8-validate + metro@0.84.5(bufferutil@4.1.0)(utf-8-validate@6.0.6): + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/core': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + accepts: 2.0.0 + ci-info: 2.0.0 + connect: 3.7.0 + debug: 4.4.3 + error-stack-parser: 2.1.4 + flow-enums-runtime: 0.0.6 + graceful-fs: 4.2.11 + hermes-parser: 0.35.0 + invariant: 2.2.4 + jest-worker: 29.7.0 + jsc-safe-url: 0.2.4 + lodash.throttle: 4.1.1 + metro-babel-transformer: 0.84.5 + metro-cache: 0.84.5 + metro-cache-key: 0.84.5 + metro-config: 0.84.5(bufferutil@4.1.0)(utf-8-validate@6.0.6) + metro-core: 0.84.5 + metro-file-map: 0.84.5 + metro-resolver: 0.84.5 + metro-runtime: 0.84.5 + metro-source-map: 0.84.5 + metro-symbolicate: 0.84.5 + metro-transform-plugins: 0.84.5 + metro-transform-worker: 0.84.5(bufferutil@4.1.0)(utf-8-validate@6.0.6) + mime-types: 3.0.2 + nullthrows: 1.1.1 + serialize-error: 2.1.0 + source-map: 0.5.7 + throat: 5.0.0 + ws: 7.5.11(bufferutil@4.1.0)(utf-8-validate@6.0.6) + yargs: 17.7.2 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + micromark-core-commonmark@2.0.3: dependencies: decode-named-character-reference: 1.3.0 @@ -18811,6 +18434,16 @@ snapshots: micromark-util-symbol: 2.0.1 micromark-util-types: 2.0.2 + micromark-extension-directive@4.0.0: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-factory-whitespace: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + parse-entities: 4.0.2 + micromark-extension-gfm-autolink-literal@2.1.0: dependencies: micromark-util-character: 2.1.1 @@ -19018,9 +18651,6 @@ snapshots: mimic-response@3.1.0: {} - min-indent@1.0.1: - optional: true - minimatch@10.2.5: dependencies: brace-expansion: 5.0.6 @@ -19109,14 +18739,16 @@ snapshots: typescript: 6.0.3 transitivePeerDependencies: - '@types/node' + optional: true muggle-string@0.4.1: {} multipasta@0.2.8: {} - multitars@1.0.0: {} + multitars@1.0.2: {} - mute-stream@2.0.0: {} + mute-stream@2.0.0: + optional: true mysql2@3.22.4(@types/node@24.12.4): dependencies: @@ -19187,7 +18819,7 @@ snapshots: semver: 7.8.5 tar: 7.5.16 tinyglobby: 0.2.17 - undici: 6.26.0 + undici: 6.28.0 which: 6.0.1 node-int64@0.4.0: {} @@ -19260,6 +18892,10 @@ snapshots: dependencies: flow-enums-runtime: 0.0.6 + ob1@0.84.5: + dependencies: + flow-enums-runtime: 0.0.6 + object-assign@4.1.1: {} object-inspect@1.13.4: {} @@ -19338,7 +18974,8 @@ snapshots: stdin-discarder: 0.3.2 string-width: 8.2.1 - outvariant@1.4.3: {} + outvariant@1.4.3: + optional: true oxfmt@0.57.0(vite-plus@0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)): dependencies: @@ -19488,8 +19125,6 @@ snapshots: pe-library@0.4.1: {} - pend@1.2.0: {} - pg-cloudflare@1.4.0: optional: true @@ -19764,18 +19399,10 @@ snapshots: dependencies: react: 19.2.6 - react-fast-compare@3.2.2: - optional: true - react-freeze@1.0.4(react@19.2.3): dependencies: react: 19.2.3 - react-freeze@1.0.4(react@19.2.6): - dependencies: - react: 19.2.6 - optional: true - react-grab@0.1.44(react@19.2.6): dependencies: '@react-grab/cli': 0.1.44 @@ -19809,159 +19436,100 @@ snapshots: transitivePeerDependencies: - supports-color - react-native-drawer-layout@4.2.4(05364bd849de538917a7364cc7dee3f5): - dependencies: - color: 4.2.3 - react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - react-native-gesture-handler: 2.31.2(patch_hash=808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d3)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - react-native-reanimated: 4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - use-latest-callback: 0.2.6(react@19.2.3) - optional: true - - react-native-drawer-layout@4.2.4(de9b2f2dc96a3557fdc0df187a8417ee): - dependencies: - color: 4.2.3 - react: 19.2.6 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) - react-native-gesture-handler: 2.31.2(patch_hash=808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d3)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) - react-native-reanimated: 4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) - use-latest-callback: 0.2.6(react@19.2.6) - optional: true - - react-native-gesture-handler@2.31.2(patch_hash=808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d3)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-gesture-handler@2.32.0(patch_hash=808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d3)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: '@egjs/hammerjs': 2.0.17 '@types/react-test-renderer': 19.1.0 hoist-non-react-statics: 3.3.2 invariant: 2.2.4 react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - - react-native-gesture-handler@2.31.2(patch_hash=808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d3)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6): - dependencies: - '@egjs/hammerjs': 2.0.17 - '@types/react-test-renderer': 19.1.0 - hoist-non-react-statics: 3.3.2 - invariant: 2.2.4 - react: 19.2.6 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) - optional: true + react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - react-native-image-viewing@0.2.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-image-viewing@0.2.2(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - react-native-is-edge-to-edge@1.3.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-is-edge-to-edge@1.3.1(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - - react-native-is-edge-to-edge@1.3.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6): - dependencies: - react: 19.2.6 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) - optional: true + react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - react-native-keyboard-controller@1.21.13(patch_hash=20be72c84d74253acdcfefbc6defe36dc396944f1a44cab2bdd0e3cd572ae008)(react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-keyboard-controller@1.21.13(patch_hash=6e4339347bc5bb3c9ea67d85ff5c814058b211c5750f247aba59d07869a2e787)(react-native-reanimated@4.5.1(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - react-native-is-edge-to-edge: 1.3.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - react-native-reanimated: 4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native-is-edge-to-edge: 1.3.1(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native-reanimated: 4.5.1(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - react-native-nitro-markdown@0.5.8(react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-svg@15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-nitro-markdown@0.5.8(react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-svg@15.15.4(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - react-native-nitro-modules: 0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native-nitro-modules: 0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) optionalDependencies: - react-native-svg: 15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native-svg: 15.15.4(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-reanimated@4.5.1(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - react-native-is-edge-to-edge: 1.3.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - react-native-worklets: 0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - semver: 7.8.5 - - react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6): - dependencies: - react: 19.2.6 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) - react-native-is-edge-to-edge: 1.3.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) - react-native-worklets: 0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) + react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native-is-edge-to-edge: 1.3.1(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native-worklets: 0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) semver: 7.8.5 - optional: true - react-native-safe-area-context@5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-safe-area-context@5.7.0(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - - react-native-safe-area-context@5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6): - dependencies: - react: 19.2.6 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) - optional: true + react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - react-native-screens@4.25.2(patch_hash=59bfd7b84af01708b6e581c4ccdd5ecf05f8b205383802d66b64ed7ea7bb2199)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-screens@4.26.2(patch_hash=149bef30a66351ea9b26b42f87b78c539cb52b880f2387bb323ec80dcac84006)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: react: 19.2.3 react-freeze: 1.0.4(react@19.2.3) - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) warn-once: 0.1.1 - react-native-screens@4.25.2(patch_hash=59bfd7b84af01708b6e581c4ccdd5ecf05f8b205383802d66b64ed7ea7bb2199)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6): - dependencies: - react: 19.2.6 - react-freeze: 1.0.4(react@19.2.6) - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) - warn-once: 0.1.1 - optional: true - - react-native-shiki-engine@0.3.12(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-shiki-engine@0.3.12(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: '@shikijs/types': 4.3.0 '@shikijs/vscode-textmate': 10.0.2 react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - react-native-svg@15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-svg@15.15.4(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: css-select: 5.2.2 css-tree: 1.1.3 react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) warn-once: 0.1.1 - react-native-url-polyfill@4.0.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): + react-native-url-polyfill@4.0.0(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): dependencies: - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-webview@13.16.1(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: escape-string-regexp: 4.0.0 invariant: 2.2.4 react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6): + react-native-webview@13.16.1(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6): dependencies: escape-string-regexp: 4.0.0 invariant: 2.2.4 react: 19.2.6 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) + react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) optional: true - react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: '@babel/core': 7.29.7 '@babel/plugin-transform-arrow-functions': 7.29.7(@babel/core@7.29.7) @@ -19973,15 +19541,16 @@ snapshots: '@babel/plugin-transform-template-literals': 7.29.7(@babel/core@7.29.7) '@babel/plugin-transform-unicode-regex': 7.29.7(@babel/core@7.29.7) '@babel/preset-typescript': 7.29.7(@babel/core@7.29.7) - '@react-native/metro-config': 0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6) + '@babel/types': 7.29.7 + '@react-native/metro-config': 0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6) convert-source-map: 2.0.0 react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) semver: 7.8.5 transitivePeerDependencies: - supports-color - react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6): + react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6): dependencies: '@babel/core': 7.29.7 '@babel/plugin-transform-arrow-functions': 7.29.7(@babel/core@7.29.7) @@ -19993,32 +19562,33 @@ snapshots: '@babel/plugin-transform-template-literals': 7.29.7(@babel/core@7.29.7) '@babel/plugin-transform-unicode-regex': 7.29.7(@babel/core@7.29.7) '@babel/preset-typescript': 7.29.7(@babel/core@7.29.7) - '@react-native/metro-config': 0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6) + '@babel/types': 7.29.7 + '@react-native/metro-config': 0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6) convert-source-map: 2.0.0 react: 19.2.6 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) + react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) semver: 7.8.5 transitivePeerDependencies: - supports-color optional: true - react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6): + react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6): dependencies: - '@react-native/assets-registry': 0.85.3 - '@react-native/codegen': 0.85.3(@babel/core@7.29.7) - '@react-native/community-cli-plugin': 0.85.3(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(bufferutil@4.1.0)(utf-8-validate@6.0.6) - '@react-native/gradle-plugin': 0.85.3(patch_hash=c1b594a16e682d621b6a960926f8ce13fc92edfb397884cfe7fcb0518996a784) - '@react-native/js-polyfills': 0.85.3 - '@react-native/normalize-colors': 0.85.3 - '@react-native/virtualized-lists': 0.85.3(@types/react@19.2.16)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + '@react-native/assets-registry': 0.86.3 + '@react-native/codegen': 0.86.3(@babel/core@7.29.7) + '@react-native/community-cli-plugin': 0.86.3(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(bufferutil@4.1.0)(utf-8-validate@6.0.6) + '@react-native/gradle-plugin': 0.86.3 + '@react-native/js-polyfills': 0.86.3 + '@react-native/normalize-colors': 0.86.3 + '@react-native/virtualized-lists': 0.86.3(@types/react@19.2.16)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) abort-controller: 3.0.0 anser: 1.4.10 ansi-regex: 5.0.1 - babel-plugin-syntax-hermes-parser: 0.33.3 + babel-plugin-syntax-hermes-parser: 0.36.0 base64-js: 1.5.1 commander: 12.1.0 flow-enums-runtime: 0.0.6 - hermes-compiler: 250829098.0.10 + hermes-compiler: 250829098.0.17 invariant: 2.2.4 memoize-one: 5.2.1 metro-runtime: 0.84.4 @@ -20047,23 +19617,23 @@ snapshots: - supports-color - utf-8-validate - react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6): + react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6): dependencies: - '@react-native/assets-registry': 0.85.3 - '@react-native/codegen': 0.85.3(@babel/core@7.29.7) - '@react-native/community-cli-plugin': 0.85.3(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(bufferutil@4.1.0)(utf-8-validate@6.0.6) - '@react-native/gradle-plugin': 0.85.3(patch_hash=c1b594a16e682d621b6a960926f8ce13fc92edfb397884cfe7fcb0518996a784) - '@react-native/js-polyfills': 0.85.3 - '@react-native/normalize-colors': 0.85.3 - '@react-native/virtualized-lists': 0.85.3(@types/react@19.2.16)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) + '@react-native/assets-registry': 0.86.3 + '@react-native/codegen': 0.86.3(@babel/core@7.29.7) + '@react-native/community-cli-plugin': 0.86.3(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(bufferutil@4.1.0)(utf-8-validate@6.0.6) + '@react-native/gradle-plugin': 0.86.3 + '@react-native/js-polyfills': 0.86.3 + '@react-native/normalize-colors': 0.86.3 + '@react-native/virtualized-lists': 0.86.3(@types/react@19.2.16)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) abort-controller: 3.0.0 anser: 1.4.10 ansi-regex: 5.0.1 - babel-plugin-syntax-hermes-parser: 0.33.3 + babel-plugin-syntax-hermes-parser: 0.36.0 base64-js: 1.5.1 commander: 12.1.0 flow-enums-runtime: 0.0.6 - hermes-compiler: 250829098.0.10 + hermes-compiler: 250829098.0.17 invariant: 2.2.4 memoize-one: 5.2.1 metro-runtime: 0.84.4 @@ -20108,15 +19678,6 @@ snapshots: optionalDependencies: '@types/react': 19.2.16 - react-remove-scroll-bar@2.3.8(@types/react@19.2.16)(react@19.2.6): - dependencies: - react: 19.2.6 - react-style-singleton: 2.2.3(@types/react@19.2.16)(react@19.2.6) - tslib: 2.8.1 - optionalDependencies: - '@types/react': 19.2.16 - optional: true - react-remove-scroll@2.7.2(@types/react@19.2.16)(react@19.2.3): dependencies: react: 19.2.3 @@ -20128,18 +19689,6 @@ snapshots: optionalDependencies: '@types/react': 19.2.16 - react-remove-scroll@2.7.2(@types/react@19.2.16)(react@19.2.6): - dependencies: - react: 19.2.6 - react-remove-scroll-bar: 2.3.8(@types/react@19.2.16)(react@19.2.6) - react-style-singleton: 2.2.3(@types/react@19.2.16)(react@19.2.6) - tslib: 2.8.1 - use-callback-ref: 1.3.3(@types/react@19.2.16)(react@19.2.6) - use-sidecar: 1.1.3(@types/react@19.2.16)(react@19.2.6) - optionalDependencies: - '@types/react': 19.2.16 - optional: true - react-style-singleton@2.2.3(@types/react@19.2.16)(react@19.2.3): dependencies: get-nonce: 1.0.1 @@ -20148,15 +19697,6 @@ snapshots: optionalDependencies: '@types/react': 19.2.16 - react-style-singleton@2.2.3(@types/react@19.2.16)(react@19.2.6): - dependencies: - get-nonce: 1.0.1 - react: 19.2.6 - tslib: 2.8.1 - optionalDependencies: - '@types/react': 19.2.16 - optional: true - react@19.2.3: {} react@19.2.6: {} @@ -20181,12 +19721,6 @@ snapshots: readdirp@5.0.0: {} - redent@3.0.0: - dependencies: - indent-string: 4.0.0 - strip-indent: 3.0.0 - optional: true - redis-errors@1.2.0: {} redis-parser@3.0.0: @@ -20384,7 +19918,8 @@ snapshots: retry@0.12.0: {} - rettime@0.10.1: {} + rettime@0.10.1: + optional: true reusify@1.1.0: {} @@ -20497,6 +20032,8 @@ snapshots: safer-buffer@2.1.2: {} + sandbox-cli-detector@0.2.0: {} + sanitize-filename@1.6.4: dependencies: truncate-utf8-bytes: 1.0.2 @@ -20605,18 +20142,12 @@ snapshots: transitivePeerDependencies: - supports-color - server-only@0.0.1: - optional: true - setimmediate@1.0.5: {} setprototypeof@1.2.0: {} sf-symbols-typescript@2.2.0: {} - shallowequal@1.1.0: - optional: true - sharp@0.34.5: dependencies: '@img/colour': 1.1.0 @@ -20799,9 +20330,6 @@ snapshots: standard-as-callback@2.1.0: {} - standard-navigation@0.0.5: - optional: true - standard-navigation@0.0.7: {} standardwebhooks@1.0.0: @@ -20821,7 +20349,8 @@ snapshots: stream-buffers@2.2.0: {} - strict-event-emitter@0.5.1: {} + strict-event-emitter@0.5.1: + optional: true strict-uri-encode@2.0.0: {} @@ -20863,11 +20392,6 @@ snapshots: dependencies: ansi-regex: 6.2.2 - strip-indent@3.0.0: - dependencies: - min-indent: 1.0.1 - optional: true - strnum@2.3.0: {} structured-headers@0.4.1: {} @@ -20937,10 +20461,10 @@ snapshots: tailwind-merge@3.6.0: {} - tailwindcss@4.2.1: {} - tailwindcss@4.3.0: {} + tailwindcss@4.3.2: {} + tapable@2.3.3: {} tar@7.5.16: @@ -21002,11 +20526,13 @@ snapshots: tinyrainbow@3.1.0: {} - tldts-core@7.4.2: {} + tldts-core@7.4.2: + optional: true tldts@7.4.2: dependencies: tldts-core: 7.4.2 + optional: true tmp-promise@3.0.3: dependencies: @@ -21031,6 +20557,7 @@ snapshots: tough-cookie@6.0.1: dependencies: tldts: 7.4.2 + optional: true tr46@0.0.3: {} @@ -21077,6 +20604,8 @@ snapshots: typescript@6.0.3: {} + ua-parser-js@0.7.41: {} + ufo@1.6.4: {} ultrahtml@1.6.0: {} @@ -21085,7 +20614,7 @@ snapshots: undici-types@7.16.0: {} - undici@6.26.0: {} + undici@6.28.0: {} undici@7.27.1: {} @@ -21174,15 +20703,20 @@ snapshots: universalify@2.0.1: {} - uniwind@1.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(tailwindcss@4.3.0): + uniwind@1.11.0(patch_hash=329a77525509623d763b738152dbd00ab392cdcdd9fb6ed2b4a20e086e437196)(@expo/metro-config@57.0.12(patch_hash=96f1a75347e6ea02dc4b7034ace815d8ee39e18b8166ebfb573d9e58328f0dc2)(bufferutil@4.1.0)(expo@57.0.18)(typescript@6.0.3)(utf-8-validate@6.0.6))(metro-cache@0.84.5)(metro-transform-worker@0.84.5(bufferutil@4.1.0)(utf-8-validate@6.0.6))(metro@0.84.5(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(tailwindcss@4.3.0): dependencies: - '@tailwindcss/node': 4.2.1 - '@tailwindcss/oxide': 4.2.1 + '@tailwindcss/node': 4.3.2 + '@tailwindcss/oxide': 4.3.2 culori: 4.0.2 lightningcss: 1.30.1 + metro: 0.84.5(bufferutil@4.1.0)(utf-8-validate@6.0.6) + metro-cache: 0.84.5 react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) tailwindcss: 4.3.0 + optionalDependencies: + '@expo/metro-config': 57.0.12(patch_hash=96f1a75347e6ea02dc4b7034ace815d8ee39e18b8166ebfb573d9e58328f0dc2)(bufferutil@4.1.0)(expo@57.0.18)(typescript@6.0.3)(utf-8-validate@6.0.6) + metro-transform-worker: 0.84.5(bufferutil@4.1.0)(utf-8-validate@6.0.6) unpipe@1.0.0: {} @@ -21212,7 +20746,8 @@ snapshots: idb-keyval: 6.2.1 ioredis: 5.11.0 - until-async@3.0.2: {} + until-async@3.0.2: + optional: true unzipper@0.12.5: dependencies: @@ -21240,23 +20775,10 @@ snapshots: optionalDependencies: '@types/react': 19.2.16 - use-callback-ref@1.3.3(@types/react@19.2.16)(react@19.2.6): - dependencies: - react: 19.2.6 - tslib: 2.8.1 - optionalDependencies: - '@types/react': 19.2.16 - optional: true - use-latest-callback@0.2.6(react@19.2.3): dependencies: react: 19.2.3 - use-latest-callback@0.2.6(react@19.2.6): - dependencies: - react: 19.2.6 - optional: true - use-sidecar@1.1.3(@types/react@19.2.16)(react@19.2.3): dependencies: detect-node-es: 1.1.0 @@ -21265,15 +20787,6 @@ snapshots: optionalDependencies: '@types/react': 19.2.16 - use-sidecar@1.1.3(@types/react@19.2.16)(react@19.2.6): - dependencies: - detect-node-es: 1.1.0 - react: 19.2.6 - tslib: 2.8.1 - optionalDependencies: - '@types/react': 19.2.16 - optional: true - use-sync-external-store@1.6.0(react@19.2.3): dependencies: react: 19.2.3 @@ -21310,16 +20823,6 @@ snapshots: - '@types/react' - '@types/react-dom' - vaul@1.1.2(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6): - dependencies: - '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - react: 19.2.6 - react-dom: 19.2.6(react@19.2.6) - transitivePeerDependencies: - - '@types/react' - - '@types/react-dom' - optional: true - vfile-location@5.0.3: dependencies: '@types/unist': 3.0.3 @@ -21600,6 +21103,7 @@ snapshots: ansi-styles: 4.3.0 string-width: 4.2.3 strip-ansi: 6.0.1 + optional: true wrap-ansi@7.0.0: dependencies: @@ -21683,11 +21187,6 @@ snapshots: y18n: 5.0.8 yargs-parser: 21.1.1 - yauzl@2.10.0: - dependencies: - buffer-crc32: 0.2.13 - fd-slicer: 1.1.0 - yjs@13.6.31: dependencies: lib0: 0.2.117 @@ -21696,7 +21195,8 @@ snapshots: yocto-queue@1.2.2: {} - yoctocolors-cjs@2.1.3: {} + yoctocolors-cjs@2.1.3: + optional: true yoctocolors@2.1.2: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 0a4d3cc7cb50..bbfde3775f7b 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -24,12 +24,12 @@ allowBuilds: catalog: "@clerk/backend": 3.14.0 - "@clerk/clerk-js": 6.29.2 - "@clerk/electron": 0.0.34 + "@clerk/clerk-js": 6.30.1 + "@clerk/electron": 0.0.37 "@clerk/electron-passkeys": 0.0.3 "@clerk/expo": 4.2.0 - "@clerk/react": 6.14.4 - "@clerk/shared": 4.29.2 + "@clerk/react": 6.14.7 + "@clerk/shared": 4.30.1 "@effect/atom-react": 4.0.0-beta.103 "@effect/openapi-generator": 4.0.0-beta.103 "@effect/platform-bun": 4.0.0-beta.103 @@ -54,11 +54,11 @@ catalog: minimumReleaseAgeExclude: - "@clerk/backend@3.14.0" - - "@clerk/clerk-js@6.29.2" - - "@clerk/electron@0.0.34" + - "@clerk/clerk-js@6.30.1" + - "@clerk/electron@0.0.37" - "@clerk/expo@4.2.0" - - "@clerk/react@6.14.4" - - "@clerk/shared@4.29.2" + - "@clerk/react@6.14.7" + - "@clerk/shared@4.30.1" - "@distilled.cloud/aws@0.30.2" - "@distilled.cloud/axiom@0.30.2" - "@distilled.cloud/cloudflare@0.30.2" @@ -76,6 +76,15 @@ minimumReleaseAgeExclude: - alchemy@2.0.0-beta.65 - effect@4.0.0-beta.103 - "@legendapp/list@3.3.5" + - "@expo/cli@57.0.20" + - "@expo/env@2.4.3" + - "@expo/fingerprint@0.20.11" + - "@expo/metro-config@57.0.12" + - expo-constants@57.0.16 + - expo-font@57.0.2 + - expo-updates@57.0.19 + - expo@57.0.18 + - expo-widgets@57.0.15 overrides: # The SDK always receives the user's Claude executable, so its bundled binaries are unused. @@ -108,20 +117,22 @@ overrides: "@effect/sql-sqlite-bun": "catalog:" "@effect/vitest": "catalog:" "@effect/vitest>vitest": "-" - "@expo/metro-config": 56.0.14 + "@expo/dom-webview": 57.0.1 + "@expo/metro-config": 57.0.12 + expo-constants: 57.0.16 "@pierre/diffs>@shikijs/transformers": ^4.2.0 "@types/node": "catalog:" effect: "catalog:" - expo-modules-jsi: 56.0.10 - "expo-sharing>@expo/config-plugins": 56.0.9 - "expo-sharing>@expo/config-types": 56.0.6 + expo-router: 57.0.17 + "expo-sharing>@expo/config-plugins": 57.0.9 + "expo-sharing>@expo/config-types": 57.0.2 vite: "catalog:" yaml: "catalog:" packageExtensions: "@clerk/expo@*": dependencies: - "@expo/config-plugins": 56.0.9 + "@expo/config-plugins": 57.0.9 "@effect/vitest@*": dependencies: vite-plus: "catalog:" @@ -135,19 +146,21 @@ packageExtensions: patchedDependencies: "@clerk/expo@4.2.0": patches/@clerk__expo@4.2.0.patch "@effect/vitest@4.0.0-beta.103": patches/@effect__vitest@4.0.0-beta.103.patch - "@expo/metro-config@56.0.14": patches/@expo%2Fmetro-config@56.0.14.patch + "@expo/metro-config@57.0.12": patches/@expo__metro-config@57.0.12.patch "@ff-labs/fff-node@0.9.4": patches/@ff-labs__fff-node@0.9.4.patch "@legendapp/list@3.3.5": patches/@legendapp__list@3.3.5.patch "@pierre/diffs@1.3.0-beta.10": patches/@pierre%2Fdiffs@1.3.0-beta.10.patch + "@react-native-ai/apple@0.12.0": patches/@react-native-ai__apple@0.12.0.patch "@react-native-menu/menu@2.0.0": patches/@react-native-menu__menu@2.0.0.patch - "@react-native/gradle-plugin@0.85.3": patches/@react-native__gradle-plugin@0.85.3.patch "@react-navigation/native-stack@7.17.6": patches/@react-navigation%2Fnative-stack@7.17.6.patch effect@4.0.0-beta.103: patches/effect@4.0.0-beta.103.patch - expo-modules-jsi@56.0.10: patches/expo-modules-jsi@56.0.10.patch - react-native-gesture-handler@2.31.2: patches/react-native-gesture-handler@2.31.2.patch + expo-audio@57.0.4: patches/expo-audio@57.0.4.patch + expo-sharing@57.0.16: patches/expo-sharing@57.0.16.patch + react-native-gesture-handler@2.32.0: patches/react-native-gesture-handler@2.32.0.patch react-native-keyboard-controller@1.21.13: patches/react-native-keyboard-controller@1.21.13.patch react-native-nitro-modules@0.35.9: patches/react-native-nitro-modules@0.35.9.patch - react-native-screens@4.25.2: patches/react-native-screens@4.25.2.patch + react-native-screens@4.26.2: patches/react-native-screens@4.26.2.patch + uniwind@1.11.0: patches/uniwind@1.11.0.patch peerDependencyRules: allowAny: diff --git a/scripts/announce-connect-ga.ts b/scripts/announce-connect-ga.ts deleted file mode 100644 index 88a4db56cc72..000000000000 --- a/scripts/announce-connect-ga.ts +++ /dev/null @@ -1,226 +0,0 @@ -#!/usr/bin/env node - -import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; -import * as NodeServices from "@effect/platform-node/NodeServices"; -import * as Config from "effect/Config"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Logger from "effect/Logger"; -import * as Option from "effect/Option"; -import * as Schema from "effect/Schema"; -import { Command, Flag } from "effect/unstable/cli"; -import { - FetchHttpClient, - HttpClient, - HttpClientRequest, - HttpClientResponse, -} from "effect/unstable/http"; - -const CLERK_API_URL = "https://api.clerk.com/v1"; -const PAGE_SIZE = 500; - -export class WaitlistEntry extends Schema.Class("WaitlistEntry")({ - id: Schema.String, - email_address: Schema.String, - status: Schema.Literals(["pending", "invited", "completed", "rejected"]), -}) {} - -const ClerkWaitlistResponse = Schema.Struct({ - data: Schema.Array(WaitlistEntry), - total_count: Schema.Int, -}); -const PositiveInteger = Schema.Int.check(Schema.isGreaterThan(0)); -const ClerkSecretKey = Config.string("CLERK_SECRET_KEY"); - -export interface ConnectGaOptions { - readonly invite: boolean; - readonly limit: number | undefined; -} - -export class ConnectGaRequestError extends Schema.TaggedErrorClass()( - "ConnectGaRequestError", - { - operation: Schema.String, - cause: Schema.Defect(), - }, -) { - override get message(): string { - return `Clerk ${this.operation} request failed.`; - } -} - -export class ConnectGaResponseError extends Schema.TaggedErrorClass()( - "ConnectGaResponseError", - { - operation: Schema.String, - status: Schema.Int, - cause: Schema.Defect(), - }, -) { - override get message(): string { - return `Clerk ${this.operation} returned status ${this.status}.`; - } -} - -const executeClerkJsonRequest = Effect.fn("executeClerkJsonRequest")(function* < - S extends Schema.Top, ->(request: HttpClientRequest.HttpClientRequest, schema: S, operation: string) { - const client = (yield* HttpClient.HttpClient).pipe( - HttpClient.retryTransient({ - retryOn: "errors-and-responses", - times: 3, - }), - ); - const response = yield* client - .execute(request) - .pipe(Effect.mapError((cause) => new ConnectGaRequestError({ operation, cause }))); - const success = yield* HttpClientResponse.filterStatusOk(response).pipe( - Effect.mapError( - (cause) => - new ConnectGaResponseError({ - operation, - status: response.status, - cause, - }), - ), - ); - return yield* HttpClientResponse.schemaBodyJson(schema)(success).pipe( - Effect.mapError( - (cause) => - new ConnectGaResponseError({ - operation, - status: response.status, - cause, - }), - ), - ); -}); - -const fetchWaitlistPage = Effect.fn("fetchWaitlistPage")(function* ( - secretKey: string, - offset: number, -) { - const url = new URL(`${CLERK_API_URL}/waitlist_entries`); - url.searchParams.set("status", "pending"); - url.searchParams.set("limit", String(PAGE_SIZE)); - url.searchParams.set("offset", String(offset)); - url.searchParams.set("order_by", "+created_at"); - const request = HttpClientRequest.get(url.href).pipe( - HttpClientRequest.bearerToken(secretKey), - HttpClientRequest.setHeader("Clerk-API-Version", "2026-05-12"), - ); - return yield* executeClerkJsonRequest( - request, - ClerkWaitlistResponse, - "list pending waitlist entries", - ); -}); - -export const fetchPendingWaitlistEntries = Effect.fn("fetchPendingWaitlistEntries")(function* ( - secretKey: string, - limit?: number, -) { - const entries: Array = []; - while (true) { - if (limit !== undefined && entries.length >= limit) break; - const page = yield* fetchWaitlistPage(secretKey, entries.length); - entries.push(...page.data); - if (entries.length >= page.total_count || page.data.length === 0) break; - } - return limit === undefined ? entries : entries.slice(0, limit); -}); - -export const inviteWaitlistEntry = Effect.fn("inviteWaitlistEntry")(function* ( - secretKey: string, - entry: WaitlistEntry, -) { - const request = HttpClientRequest.post( - `${CLERK_API_URL}/waitlist_entries/${encodeURIComponent(entry.id)}/invite`, - ).pipe( - HttpClientRequest.bearerToken(secretKey), - HttpClientRequest.setHeader("Clerk-API-Version", "2026-05-12"), - ); - return yield* executeClerkJsonRequest( - request, - WaitlistEntry, - `invite waitlist entry ${entry.id}`, - ); -}); - -export const announceConnectGa = Effect.fn("announceConnectGa")(function* ( - options: ConnectGaOptions, -) { - const clerkSecretKey = yield* ClerkSecretKey; - const entries = yield* fetchPendingWaitlistEntries(clerkSecretKey, options.limit); - - yield* Effect.logInfo( - options.invite ? "Connect GA waitlist invitations starting" : "Connect GA dry run", - ).pipe( - Effect.annotateLogs({ - pendingEntries: entries.length, - }), - ); - - if (!options.invite) { - for (const entry of entries) { - yield* Effect.logInfo("pending waitlist entry").pipe( - Effect.annotateLogs({ - waitlistEntryId: entry.id, - emailAddress: entry.email_address, - }), - ); - } - yield* Effect.logInfo("No invitation was sent. Re-run with --invite after reviewing the list."); - return; - } - - for (const [index, entry] of entries.entries()) { - const invited = yield* inviteWaitlistEntry(clerkSecretKey, entry); - yield* Effect.logInfo("Clerk waitlist invitation sent").pipe( - Effect.annotateLogs({ - waitlistEntryId: invited.id, - completed: index + 1, - total: entries.length, - }), - ); - } -}); - -export const announceConnectGaCommand = Command.make( - "announce-connect-ga", - { - invite: Flag.boolean("invite").pipe( - Flag.withDefault(false), - Flag.withDescription( - "Invite pending entries through Clerk. Without this flag, only print a dry-run list.", - ), - ), - limit: Flag.integer("limit").pipe( - Flag.withSchema(PositiveInteger), - Flag.optional, - Flag.withDescription("Process at most this many pending waitlist entries."), - ), - }, - ({ invite, limit }) => - announceConnectGa({ - invite, - limit: Option.getOrUndefined(limit), - }), -).pipe( - Command.withDescription( - "Invite pending Clerk waitlist members now that T3 Connect is generally available.", - ), -); - -if (import.meta.main) { - Command.run(announceConnectGaCommand, { version: "0.0.0" }).pipe( - Effect.provide( - Layer.mergeAll( - Logger.layer([Logger.consolePretty()]), - NodeServices.layer, - FetchHttpClient.layer, - ), - ), - NodeRuntime.runMain, - ); -} diff --git a/scripts/build-desktop-artifact.test.ts b/scripts/build-desktop-artifact.test.ts index d1c0d54589df..c12c73bfc1aa 100644 --- a/scripts/build-desktop-artifact.test.ts +++ b/scripts/build-desktop-artifact.test.ts @@ -1,3 +1,6 @@ +// @effect-diagnostics nodeBuiltinImport:off - packaged-archive fixtures compute the sidecar digest with the same Node primitive as the builder. +import * as NodeCrypto from "node:crypto"; + import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import * as ConfigProvider from "effect/ConfigProvider"; @@ -8,11 +11,13 @@ import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; -import { ChildProcessSpawner } from "effect/unstable/process"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { BundleNotSelfContainedError, BuildCommandFailedError, + buildWslRuntimeArchiveArgs, + parseWslRuntimeArchiveMembers, DesktopDmgBackgroundSourceMissingError, createStageWorkspaceConfig, createStagePatchedDependencies, @@ -20,6 +25,7 @@ import { DESKTOP_ELECTRON_LANGUAGES, DESKTOP_FILE_EXCLUSIONS, DESKTOP_EXTRA_RESOURCES, + MAC_FILE_EXCLUSIONS, InvalidMacPasskeyRpDomainError, InvalidMacPasskeyPublishableKeyError, InvalidMockUpdateServerPortError, @@ -33,6 +39,7 @@ import { resolveClerkPasskeyNativeArtifacts, resolveMacPasskeySigningConfiguration, resolveDesktopRuntimeDependencies, + resolveMacStageDependencies, resolveFffNativeDependencies, resolveBuildOptions, resolveDesktopBuildIconAssets, @@ -49,6 +56,8 @@ import { stageLinuxIconSize, stageDesktopDmgBackground, stageResourceMonitor, + stageWslRuntimeArchive, + bundlesWslRuntime, STAGE_INSTALL_ARGS, ancestorNodeModulesPaths, copyDirectoryPreservingSymlinks, @@ -61,10 +70,38 @@ import { WINDOWS_SERVER_ASAR_RESOURCE, WINDOWS_SERVER_ASAR_UNPACK_GLOB, WINDOWS_SERVER_RESOURCE_SOURCE_DIR, + WSL_RUNTIME_ARCHIVE_EXTRA_RESOURCE, + WSL_RUNTIME_ARCHIVE_HASH_EXTRA_RESOURCE, + WSL_RUNTIME_ARCHIVE_HASH_NAME, + WSL_RUNTIME_ARCHIVE_NAME, + WSL_RUNTIME_EXTRA_RESOURCES, + wslRuntimeArchiveTarTarget, } from "./build-desktop-artifact.ts"; import { BRAND_ASSET_PATHS } from "./lib/brand-assets.ts"; import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +// A minimal stand-in for the staged sidecar roots packed into the WSL archive. +const stageWslRuntimeTreeFixture = Effect.fn("stageWslRuntimeTreeFixture")(function* ( + root: string, + serverSource: string, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.makeDirectory(path.join(root, "apps/server/dist"), { recursive: true }); + yield* fs.writeFileString(path.join(root, "apps/server/dist/bin.mjs"), serverSource); + yield* fs.makeDirectory(path.join(root, "node_modules/node-pty/prebuilds/linux-x64"), { + recursive: true, + }); + yield* fs.writeFileString( + path.join(root, "node_modules/node-pty/package.json"), + '{"name":"node-pty"}\n', + ); + yield* fs.writeFileString( + path.join(root, "node_modules/node-pty/prebuilds/linux-x64/pty.node"), + "pty", + ); +}); + function mockProcess(exitCode: number) { return ChildProcessSpawner.makeHandle({ pid: ChildProcessSpawner.ProcessId(1), @@ -105,6 +142,7 @@ function iconResizeSpawnerLayer( const makeWindowsPayloadFixture = Effect.fn("test.makeWindowsPayloadFixture")(function* (input: { readonly copyUnpackedNatives: boolean; readonly serverEntrySource?: string; + readonly wslRuntime?: "valid" | "forbidden" | "bad-digest"; }) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -141,6 +179,61 @@ const makeWindowsPayloadFixture = Effect.fn("test.makeWindowsPayloadFixture")(fu yield* fs.writeFileString(path.join(packagedAppDir, appExecutableName), "electron"); yield* fs.writeFileString(path.join(packagedAppDir, "chrome_crashpad_handler.exe"), "crashpad"); + if (input.wslRuntime !== undefined) { + const wslSourceDir = path.join(tempDir, "wsl-source"); + const linuxPrebuildDir = path.join(wslSourceDir, "node_modules/node-pty/prebuilds/linux-x64"); + yield* fs.makeDirectory(path.join(wslSourceDir, "apps/server/dist"), { recursive: true }); + yield* fs.makeDirectory(linuxPrebuildDir, { recursive: true }); + yield* fs.writeFileString( + path.join(wslSourceDir, "apps/server/dist/bin.mjs"), + "console.log('wsl server');\n", + ); + yield* fs.writeFileString( + path.join(wslSourceDir, "node_modules/node-pty/package.json"), + '{"name":"node-pty"}', + ); + yield* fs.writeFileString(path.join(linuxPrebuildDir, "pty.node"), "linux-pty"); + yield* fs.writeFileString( + path.join(linuxPrebuildDir, "t3code-wsl-node-pty.json"), + '{"arch":"x64"}', + ); + if (input.wslRuntime === "forbidden") { + const windowsPrebuildDir = path.join( + wslSourceDir, + "node_modules/node-pty/prebuilds/win32-x64", + ); + yield* fs.makeDirectory(windowsPrebuildDir, { recursive: true }); + yield* fs.writeFileString(path.join(windowsPrebuildDir, "pty.node"), "windows-pty"); + } + + const archivePath = path.join(resourcesDir, WSL_RUNTIME_ARCHIVE_NAME); + const hashPath = path.join(resourcesDir, WSL_RUNTIME_ARCHIVE_HASH_NAME); + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const tar = yield* spawner.spawn( + ChildProcess.make( + "tar", + [ + "-czf", + wslRuntimeArchiveTarTarget(path.relative(wslSourceDir, archivePath)), + "apps/server/dist", + "node_modules", + ], + { cwd: wslSourceDir, stdin: "ignore", stdout: "ignore", stderr: "pipe" }, + ), + ); + assert.equal(Number(yield* tar.exitCode), 0); + const archiveDigest = NodeCrypto.createHash("sha256"); + yield* fs + .stream(archivePath) + .pipe(Stream.runForEach((chunk) => Effect.sync(() => archiveDigest.update(chunk)))); + yield* fs.writeFileString( + hashPath, + input.wslRuntime === "bad-digest" + ? `${"0".repeat(64)}\n` + : `${archiveDigest.digest("hex")}\n`, + ); + } + return { stageDistDir, packagedAppDir, @@ -221,6 +314,45 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { }), ); + it.effect("omits update feeds for pull request preview builds", () => + Effect.gen(function* () { + const preview = yield* createBuildConfig( + "mac", + "dmg", + "0.0.33-pr.8182.1", + false, + false, + undefined, + undefined, + ); + const release = yield* createBuildConfig( + "mac", + "dmg", + "0.0.33", + false, + false, + undefined, + undefined, + ); + + assert.notProperty(preview, "publish"); + assert.deepStrictEqual(release.publish, [ + { + provider: "github", + owner: "pingdotgg", + repo: "t3code", + releaseType: "release", + }, + ]); + }).pipe( + Effect.provide( + ConfigProvider.layer( + ConfigProvider.fromEnv({ env: { GITHUB_REPOSITORY: "pingdotgg/t3code" } }), + ), + ), + ), + ); + it("omits bundled workspace packages from staged desktop dependencies", () => { assert.deepStrictEqual( resolveDesktopRuntimeDependencies( @@ -393,12 +525,27 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { ); }); - it("limits Electron locales and excludes the unused Claude SDK executable", () => { + it("limits Electron locales and excludes separately packaged resources", () => { assert.deepStrictEqual(DESKTOP_ELECTRON_LANGUAGES, ["en-US"]); + // Every WSL staging input is emitted once at resources/, so adding one + // without its exclusion silently packs a second copy into app.asar. The + // snapshot below cannot catch that on its own: adding a resource and + // forgetting the exclusion leaves the exclusion list untouched, so it still + // matches. Assert the invariant first, where the failure names the culprit. + for (const resource of WSL_RUNTIME_EXTRA_RESOURCES) { + assert.include( + DESKTOP_FILE_EXCLUSIONS, + `!${resource.from}`, + `${resource.from} ships via extraResources and must be excluded from app.asar`, + ); + } + assert.deepStrictEqual(DESKTOP_FILE_EXCLUSIONS, [ "!**/node_modules/@anthropic-ai/claude-agent-sdk-*/**/*", "!apps/desktop/prod-resources/windows-server", "!apps/desktop/prod-resources/windows-server/**/*", + "!apps/desktop/prod-resources/wsl-runtime.tar.gz", + "!apps/desktop/prod-resources/wsl-runtime.tar.gz.sha256", ]); assert.equal(WINDOWS_SERVER_RESOURCE_SOURCE_DIR, "apps/desktop/prod-resources/windows-server"); assert.deepStrictEqual(WINDOWS_SERVER_EXTRA_RESOURCES, [ @@ -438,6 +585,17 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { false, undefined, undefined, + true, + ); + const winWithoutWslPrebuild = yield* createBuildConfig( + "win", + "nsis", + "1.2.3", + false, + false, + undefined, + undefined, + false, ); // All platforms keep app.asar fully packed; Windows ships the server @@ -452,6 +610,16 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { to: "resource-monitor", }, ...WINDOWS_SERVER_EXTRA_RESOURCES, + ...WSL_RUNTIME_EXTRA_RESOURCES, + ]); + // No Linux prebuild means the sidecar staging never writes the archive, + // so listing it here would fail the build on a missing source file. + assert.deepStrictEqual(winWithoutWslPrebuild.extraResources, [ + { + from: "apps/desktop/prod-resources/resource-monitor", + to: "resource-monitor", + }, + ...WINDOWS_SERVER_EXTRA_RESOURCES, ]); assert.deepStrictEqual(win.nsis, { differentialPackage: true }); // Native binaries and helper executables cannot load from inside an @@ -483,13 +651,52 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { assert.deepStrictEqual((linux.linux as Record).protocols, [ { name: "T3 Code", schemes: ["t3code", "t3code-dev"] }, ]); - for (const config of [mac, linux, win]) { + assert.deepStrictEqual(mac.files, [...DESKTOP_FILE_EXCLUSIONS, ...MAC_FILE_EXCLUSIONS]); + assert.notProperty(mac.mac as Record, "sign"); + for (const config of [linux, win]) { assert.deepStrictEqual(config.electronLanguages, DESKTOP_ELECTRON_LANGUAGES); assert.deepStrictEqual(config.files, DESKTOP_FILE_EXCLUSIONS); } + assert.deepStrictEqual(mac.electronLanguages, DESKTOP_ELECTRON_LANGUAGES); }).pipe(Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })))), ); + it("excludes Windows terminal binaries only from macOS packages", () => { + assert.deepStrictEqual(MAC_FILE_EXCLUSIONS, [ + "!**/node_modules/node-pty/prebuilds/win32-*/**/*", + "!**/node_modules/node-pty/third_party/conpty/**/*", + ]); + }); + + it("stages only server runtime externals in macOS packages", () => { + assert.deepStrictEqual( + resolveMacStageDependencies({ + serverDependencies: { + "@anthropic-ai/claude-agent-sdk": "^0.3.170", + "@ff-labs/fff-node": "0.9.4", + "@opencode-ai/sdk": "^1.3.15", + "@pierre/diffs": "1.3.0", + "msgpackr-extract": "3.0.4", + "node-pty": "1.1.0", + }, + desktopDependencies: { + "@clerk/electron": "0.0.34", + effect: "4.0.0-beta.103", + }, + arch: "arm64", + fffNodeVersion: "0.9.4", + }), + { + "@ff-labs/fff-node": "0.9.4", + "msgpackr-extract": "3.0.4", + "node-pty": "1.1.0", + "@clerk/electron": "0.0.34", + effect: "4.0.0-beta.103", + "@ff-labs/fff-bin-darwin-arm64": "0.9.4", + }, + ); + }); + it("excludes node-pty binaries for the other Windows architecture", () => { assert.deepStrictEqual(resolveWindowsServerAsarIgnoreGlobs("x64"), [ ...WINDOWS_SERVER_ASAR_IGNORE_GLOBS, @@ -636,6 +843,82 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { ), ); + it.effect("validates the emitted WSL archive and its SHA-256 sidecar", () => + Effect.scoped( + Effect.gen(function* () { + const fixture = yield* makeWindowsPayloadFixture({ + copyUnpackedNatives: true, + wslRuntime: "valid", + }); + const result = yield* validateWindowsPackagedPayload({ + stageDistDir: fixture.stageDistDir, + appExecutableName: fixture.appExecutableName, + targetArch: "x64", + expectWslRuntime: true, + }); + + assert.equal(result.packagedAppDir, fixture.packagedAppDir); + }), + ), + ); + + it.effect("rejects a Windows package missing its expected WSL runtime", () => + Effect.scoped( + Effect.gen(function* () { + const fixture = yield* makeWindowsPayloadFixture({ copyUnpackedNatives: true }); + const error = yield* validateWindowsPackagedPayload({ + stageDistDir: fixture.stageDistDir, + appExecutableName: fixture.appExecutableName, + targetArch: "x64", + expectWslRuntime: true, + }).pipe(Effect.flip); + + assert.instanceOf(error, WindowsPackagedPayloadValidationError); + assert.equal(error.reason, "wsl-runtime-missing"); + }), + ), + ); + + it.effect("rejects forbidden native members in the emitted WSL archive", () => + Effect.scoped( + Effect.gen(function* () { + const fixture = yield* makeWindowsPayloadFixture({ + copyUnpackedNatives: true, + wslRuntime: "forbidden", + }); + const error = yield* validateWindowsPackagedPayload({ + stageDistDir: fixture.stageDistDir, + appExecutableName: fixture.appExecutableName, + targetArch: "x64", + expectWslRuntime: true, + }).pipe(Effect.flip); + + assert.instanceOf(error, WindowsPackagedPayloadValidationError); + assert.equal(error.reason, "wsl-runtime-invalid"); + }), + ), + ); + + it.effect("rejects an emitted WSL archive whose sidecar digest does not match", () => + Effect.scoped( + Effect.gen(function* () { + const fixture = yield* makeWindowsPayloadFixture({ + copyUnpackedNatives: true, + wslRuntime: "bad-digest", + }); + const error = yield* validateWindowsPackagedPayload({ + stageDistDir: fixture.stageDistDir, + appExecutableName: fixture.appExecutableName, + targetArch: "x64", + expectWslRuntime: true, + }).pipe(Effect.flip); + + assert.instanceOf(error, WindowsPackagedPayloadValidationError); + assert.equal(error.reason, "wsl-runtime-invalid"); + }), + ), + ); + it.effect("probes fff through the packaged Windows primary instead of helper executables", () => { const commands: Array<{ readonly command: string; @@ -1112,6 +1395,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { assert.equal(config.appId, "com.t3tools.t3code"); assert.equal(mac.entitlements, "/tmp/entitlements.mac.plist"); assert.equal(mac.provisioningProfile, "/tmp/t3code.provisionprofile"); + assert.match(String(mac.sign), /\/scripts\/sign-macos\.ts$/); assert.deepStrictEqual(mac.protocols, [ { name: "T3 Code", schemes: ["t3code", "t3code-dev"] }, ]); @@ -1176,6 +1460,206 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { assert.equal(resourceMonitorExecutableName("mac"), "t3-resource-monitor"); assert.equal(resourceMonitorExecutableName("win"), "t3-resource-monitor.exe"); }); + + it("packages the WSL server and production dependencies as one compressed runtime", () => { + assert.equal(WSL_RUNTIME_ARCHIVE_NAME, "wsl-runtime.tar.gz"); + assert.equal(WSL_RUNTIME_ARCHIVE_HASH_NAME, "wsl-runtime.tar.gz.sha256"); + assert.deepStrictEqual(WSL_RUNTIME_ARCHIVE_EXTRA_RESOURCE, { + from: "apps/desktop/prod-resources/wsl-runtime.tar.gz", + to: "wsl-runtime.tar.gz", + }); + assert.deepStrictEqual(WSL_RUNTIME_ARCHIVE_HASH_EXTRA_RESOURCE, { + from: "apps/desktop/prod-resources/wsl-runtime.tar.gz.sha256", + to: "wsl-runtime.tar.gz.sha256", + }); + // The archive is only usable alongside a Linux pty.node, so both the + // staging and the packaging config hang off this one decision. + assert.isTrue(bundlesWslRuntime({ arch: "x64", prebuildPath: "/tmp/pty.node" })); + assert.isTrue(bundlesWslRuntime({ arch: "arm64", prebuildPath: "/tmp/pty.node" })); + assert.isFalse(bundlesWslRuntime({ arch: "x64", prebuildPath: undefined })); + assert.isFalse(bundlesWslRuntime({ arch: "universal", prebuildPath: "/tmp/pty.node" })); + + assert.deepStrictEqual(buildWslRuntimeArchiveArgs(), [ + "-czf", + "apps/desktop/prod-resources/wsl-runtime.tar.gz", + "--exclude=node_modules/@anthropic-ai/claude-agent-sdk-*", + "--exclude=node_modules/.bin*", + "--exclude=node_modules/.pnpm*", + "--exclude=node_modules/.modules.yaml*", + "--exclude=node_modules/.pnpm-workspace-state-v1.json*", + "--exclude=node_modules/node-pty/prebuilds/darwin-*", + "--exclude=node_modules/node-pty/prebuilds/win32-*", + "--exclude=node_modules/node-pty/build*", + "--exclude=node_modules/node-pty/third_party/conpty*", + "--exclude=node_modules/@ff-labs/fff-bin-win32-*", + "--exclude=node_modules/@yuuang/ffi-rs-win32-*", + "--exclude=node_modules/@msgpackr-extract/msgpackr-extract-win32-*", + "apps/server/dist", + "node_modules", + ]); + }); + + it("parses Windows bsdtar member listings with CRLF line endings", () => { + assert.deepStrictEqual( + parseWslRuntimeArchiveMembers( + "./apps/server/dist/bin.mjs\r\nnode_modules/node-pty/package.json\r\n", + ), + ["apps/server/dist/bin.mjs", "node_modules/node-pty/package.json"], + ); + }); + + it("keeps Windows tar targets colon-free so GNU tar does not read them as remote hosts", () => { + assert.equal( + wslRuntimeArchiveTarTarget("..\\app\\apps\\desktop\\prod-resources\\wsl-runtime.tar.gz"), + "../app/apps/desktop/prod-resources/wsl-runtime.tar.gz", + ); + assert.equal( + wslRuntimeArchiveTarTarget("../app/apps/desktop/prod-resources/wsl-runtime.tar.gz"), + "../app/apps/desktop/prod-resources/wsl-runtime.tar.gz", + ); + }); + + // The staged source tree and the archive live in sibling stage directories, + // so this covers the real call: on Windows the archive path is an absolute + // C:\... path, and handing that to tar is what made Git's GNU tar try to + // reach a host named "C". + it.effect("spawns tar with an archive target relative to the staged source tree", () => { + const commands: Array<{ + readonly command: string; + readonly args: ReadonlyArray; + readonly options: { readonly cwd?: string }; + }> = []; + + return Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const stageRoot = yield* fs.makeTempDirectoryScoped({ prefix: "t3-wsl-runtime-archive-" }); + const sourceDir = path.join(stageRoot, "server"); + const stageAppDir = path.join(stageRoot, "app"); + const archivePath = path.join(stageAppDir, WSL_RUNTIME_ARCHIVE_EXTRA_RESOURCE.from); + const hashPath = path.join(stageAppDir, WSL_RUNTIME_ARCHIVE_HASH_EXTRA_RESOURCE.from); + yield* stageWslRuntimeTreeFixture(sourceDir, "export const serve = 1;\n"); + + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => { + const childProcess = command as unknown as (typeof commands)[number]; + commands.push(childProcess); + // Stand in for tar: write the archive by resolving the -f target + // against the cwd tar was spawned in, exactly as tar would. + const target = path.resolve(childProcess.options.cwd ?? "", childProcess.args[1] ?? ""); + return Effect.as(fs.writeFileString(target, "wsl-runtime-archive"), mockProcess(0)); + }), + ); + + yield* stageWslRuntimeArchive({ sourceDir, archivePath, hashPath }).pipe( + Effect.provide(spawnerLayer), + ); + + const tarCommand = commands.find((command) => command.command === "tar"); + if (tarCommand === undefined) return assert.fail("tar was not spawned"); + + const target = tarCommand.args[1] ?? ""; + assert.equal(tarCommand.options.cwd, sourceDir); + assert.notInclude(target, ":"); + assert.isFalse(path.isAbsolute(target)); + // Relative or not, tar has to land the archive where the build expects it. + assert.equal(path.resolve(sourceDir, target), archivePath); + assert.isTrue(yield* fs.exists(archivePath)); + + // The archive digest both gates installation and names the cache. + const hash = yield* fs.readFileString(hashPath); + assert.match(hash.trim(), /^[0-9a-f]{64}$/); + }), + ); + }); + + it.effect("ships only Linux runtime members in the WSL archive", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-wsl-runtime-members-" }); + const sourceDir = path.join(root, "server"); + const archivePath = path.join(root, "wsl-runtime.tar.gz"); + const hashPath = `${archivePath}.sha256`; + yield* stageWslRuntimeTreeFixture(sourceDir, "export const serve = 1;\n"); + + const members = [ + "node_modules/node-pty/prebuilds/darwin-x64/pty.node", + "node_modules/node-pty/prebuilds/win32-x64/pty.node", + "node_modules/node-pty/build/Release/pty.node", + "node_modules/node-pty/third_party/conpty/win10-x64/conpty.dll", + "node_modules/@ff-labs/fff-bin-win32-x64/fff.dll", + "node_modules/@ff-labs/fff-bin-linux-x64-gnu/libfff.so", + "node_modules/@yuuang/ffi-rs-win32-x64-msvc/ffi.dll", + "node_modules/@yuuang/ffi-rs-linux-x64-gnu/libffi.so", + "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64/addon.node", + "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64/addon.node", + "node_modules/@anthropic-ai/claude-agent-sdk-win32-x64/index.js", + "node_modules/.bin/tool", + "node_modules/.pnpm/lock.yaml", + "node_modules/.modules.yaml", + "node_modules/.pnpm-workspace-state-v1.json", + ] as const; + yield* Effect.forEach( + members, + (member) => + Effect.gen(function* () { + const memberPath = path.join(sourceDir, member); + yield* fs.makeDirectory(path.dirname(memberPath), { recursive: true }); + yield* fs.writeFileString(memberPath, member); + }), + { discard: true }, + ); + + yield* stageWslRuntimeArchive({ sourceDir, archivePath, hashPath }); + const process = yield* spawner.spawn( + ChildProcess.make("tar", ["-tzf", archivePath], { + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }), + ); + const listing = yield* process.stdout.pipe( + Stream.decodeText(), + Stream.runFold( + () => "", + (output, chunk) => output + chunk, + ), + ); + assert.equal(Number(yield* process.exitCode), 0); + + assert.include(listing, "apps/server/dist/bin.mjs"); + assert.include(listing, "node_modules/node-pty/prebuilds/linux-x64/pty.node"); + assert.include(listing, "node_modules/@ff-labs/fff-bin-linux-x64-gnu/libfff.so"); + assert.include(listing, "node_modules/@yuuang/ffi-rs-linux-x64-gnu/libffi.so"); + assert.include( + listing, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64/addon.node", + ); + for (const excluded of [ + "prebuilds/darwin-", + "prebuilds/win32-", + "node-pty/build", + "third_party/conpty", + "fff-bin-win32-", + "ffi-rs-win32-", + "msgpackr-extract-win32-", + "claude-agent-sdk-", + "node_modules/.bin", + "node_modules/.pnpm", + "node_modules/.modules.yaml", + "node_modules/.pnpm-workspace-state-v1.json", + ]) { + assert.notInclude(listing, excluded); + } + }), + ), + ); + it("promotes target fff binaries to direct staged dependencies", () => { assert.deepStrictEqual(resolveFffNativeDependencies("mac", "arm64", "0.9.4"), { "@ff-labs/fff-bin-darwin-arm64": "0.9.4", diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index 3abe682b51a1..8709be2a2599 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -2,6 +2,7 @@ // @effect-diagnostics nodeBuiltinImport:off - Node's typed junction API avoids Windows symlink privileges while keeping the probe isolated. import * as NodeFSP from "node:fs/promises"; +import * as NodeCrypto from "node:crypto"; import * as NodeModule from "node:module"; import { @@ -560,6 +561,8 @@ const WindowsPackagedPayloadValidationReason = Schema.Literals([ "sidecar-invalid", "unpacked-native-missing", "resource-monitor-missing", + "wsl-runtime-missing", + "wsl-runtime-invalid", "file-limit-exceeded", ]); @@ -584,6 +587,12 @@ export class WindowsPackagedPayloadValidationError extends Schema.TaggedErrorCla if (this.reason === "resource-monitor-missing") { return "Windows packaged payload is missing the resource monitor executable."; } + if (this.reason === "wsl-runtime-missing") { + return "Windows packaged payload is missing the WSL runtime archive or SHA-256 sidecar."; + } + if (this.reason === "wsl-runtime-invalid") { + return "Windows packaged payload contains an invalid WSL runtime archive."; + } if (this.reason === "sidecar-invalid") { return "Windows packaged payload contains an invalid server.asar sidecar."; } @@ -795,14 +804,21 @@ export const DESKTOP_FILE_EXCLUSIONS = [ // staging inputs out of app.asar; they are emitted once at resources/. "!apps/desktop/prod-resources/windows-server", "!apps/desktop/prod-resources/windows-server/**/*", + "!apps/desktop/prod-resources/wsl-runtime.tar.gz", + "!apps/desktop/prod-resources/wsl-runtime.tar.gz.sha256", +] as const; +// Windows terminal helpers cannot run on macOS and slow signing and notarization. +export const MAC_FILE_EXCLUSIONS = [ + "!**/node_modules/node-pty/prebuilds/win32-*/**/*", + "!**/node_modules/node-pty/third_party/conpty/**/*", ] as const; // Windows ships the server tree (bundle + node_modules) as a separate // resources/server.asar sidecar instead of loose files: the NSIS installer // then extracts a handful of large archives instead of thousands of small // files, which dominates install (and update) time. The Windows primary runs // the server from inside server.asar via the asar-aware ELECTRON_RUN_AS_NODE -// runtime; the WSL backend cannot read asar archives, so enabling WSL lazily -// extracts the sidecar to a version-keyed directory (see DesktopWslServerTree). +// runtime. WSL normally uses the dedicated compressed Linux runtime below; +// DesktopWslServerTree can still materialize this sidecar as a fallback. export const WINDOWS_SERVER_ASAR_RESOURCE = "server.asar"; // dlopen/spawn need real files, so native modules, shared libraries, and // helper executables live in the server.asar.unpacked sibling (the standard @@ -847,6 +863,53 @@ export const WINDOWS_SERVER_EXTRA_RESOURCES = [ filter: [WINDOWS_SERVER_ASAR_RESOURCE, `${WINDOWS_SERVER_ASAR_RESOURCE}.unpacked/**/*`], }, ] as const; +export const WSL_RUNTIME_ARCHIVE_NAME = "wsl-runtime.tar.gz"; +export const WSL_RUNTIME_ARCHIVE_HASH_NAME = `${WSL_RUNTIME_ARCHIVE_NAME}.sha256`; +export const WSL_RUNTIME_ARCHIVE_EXTRA_RESOURCE = { + from: `apps/desktop/prod-resources/${WSL_RUNTIME_ARCHIVE_NAME}`, + to: WSL_RUNTIME_ARCHIVE_NAME, +} as const; +export const WSL_RUNTIME_ARCHIVE_HASH_EXTRA_RESOURCE = { + from: `apps/desktop/prod-resources/${WSL_RUNTIME_ARCHIVE_HASH_NAME}`, + to: WSL_RUNTIME_ARCHIVE_HASH_NAME, +} as const; +export const WSL_RUNTIME_ARCHIVE_CONTENT_ROOTS = ["apps/server/dist", "node_modules"] as const; + +// The WSL runtime uses only the Linux half of the shared Windows/WSL sidecar. +// Keep build/install metadata and target-native packages that cannot run in +// WSL out of the compressed archive. +export const WSL_RUNTIME_ARCHIVE_EXCLUDED_PREFIXES = [ + "node_modules/@anthropic-ai/claude-agent-sdk-", + "node_modules/.bin", + "node_modules/.pnpm", + "node_modules/.modules.yaml", + "node_modules/.pnpm-workspace-state-v1.json", + "node_modules/node-pty/prebuilds/darwin-", + "node_modules/node-pty/prebuilds/win32-", + "node_modules/node-pty/build", + "node_modules/node-pty/third_party/conpty", + "node_modules/@ff-labs/fff-bin-win32-", + "node_modules/@yuuang/ffi-rs-win32-", + "node_modules/@msgpackr-extract/msgpackr-extract-win32-", +] as const; +// WSL runs the same CPU arch as the Windows host; universal is mac-only. +export const resolveWslPrebuildArch = (arch: typeof BuildArch.Type): "x64" | "arm64" | undefined => + arch === "x64" ? "x64" : arch === "arm64" ? "arm64" : undefined; + +// A packaged WSL runtime is only usable when a Linux pty.node is bundled with +// it, so this one predicate decides both whether the archive is built and +// whether the packaging config ships it. Without it the build would produce an +// archive that can never pass the install script's payload check, and every +// launch would extract a few hundred MB from /mnt/c only to throw it away. +export const bundlesWslRuntime = (input: { + readonly arch: typeof BuildArch.Type; + readonly prebuildPath: string | undefined; +}): boolean => input.prebuildPath !== undefined && resolveWslPrebuildArch(input.arch) !== undefined; + +export const WSL_RUNTIME_EXTRA_RESOURCES = [ + WSL_RUNTIME_ARCHIVE_EXTRA_RESOURCE, + WSL_RUNTIME_ARCHIVE_HASH_EXTRA_RESOURCE, +] as const; export const DESKTOP_EXTRA_RESOURCES = [ { from: "apps/desktop/prod-resources/resource-monitor", @@ -1097,6 +1160,19 @@ export function resolveFffNativeDependencies( ); } +export function resolveMacStageDependencies(input: { + readonly serverDependencies: Record; + readonly desktopDependencies: Record; + readonly arch: typeof BuildArch.Type; + readonly fffNodeVersion: string; +}) { + return { + ...selectCliRuntimeExternalDependencies(input.serverDependencies), + ...input.desktopDependencies, + ...resolveFffNativeDependencies("mac", input.arch, input.fffNodeVersion), + }; +} + export interface ClerkPasskeyNativeArtifact { readonly packageName: string; readonly binaryFileName: string; @@ -1999,6 +2075,10 @@ export function resolveDesktopUpdateChannel(version: string): "latest" | "nightl return /-nightly\.\d{8}\.\d+$/.test(version) ? "nightly" : "latest"; } +function isDesktopPreviewVersion(version: string): boolean { + return /-pr\./.test(version); +} + export function resolveDesktopWebAssetBrand(version: string): WebAssetBrand { return resolveWebAssetBrandForChannel(resolveDesktopUpdateChannel(version)); } @@ -2055,13 +2135,17 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* ( readonly provisioningProfilePath: string; } | undefined, + // Windows only, and false when no Linux node-pty prebuild was bundled: the + // sidecar staging skips the archive in that case, and listing a resource + // whose source file was never written fails the electron-builder step. + wslRuntimeBundled = false, ) { const buildConfig: Record = { appId: DESKTOP_APP_ID, productName: resolveDesktopProductName(version), artifactName: "T3-Code-${version}-${arch}.${ext}", electronLanguages: [...DESKTOP_ELECTRON_LANGUAGES], - files: [...DESKTOP_FILE_EXCLUSIONS], + files: [...DESKTOP_FILE_EXCLUSIONS, ...(platform === "mac" ? MAC_FILE_EXCLUSIONS : [])], directories: { buildResources: "apps/desktop/resources", }, @@ -2072,22 +2156,27 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* ( extraResources: [ ...DESKTOP_EXTRA_RESOURCES, ...(platform === "win" ? WINDOWS_SERVER_EXTRA_RESOURCES : []), + ...(platform === "win" && wslRuntimeBundled ? WSL_RUNTIME_EXTRA_RESOURCES : []), ], }; const updateChannel = resolveDesktopUpdateChannel(version); - const publishConfig = yield* resolveGitHubPublishConfig(updateChannel); - if (publishConfig) { - buildConfig.publish = [publishConfig]; - } else if (mockUpdates) { - buildConfig.publish = [ - { - provider: "generic", - url: resolveMockUpdateServerUrl(mockUpdateServerPort), - }, - ]; + if (!isDesktopPreviewVersion(version)) { + const publishConfig = yield* resolveGitHubPublishConfig(updateChannel); + if (publishConfig) { + buildConfig.publish = [publishConfig]; + } else if (mockUpdates) { + buildConfig.publish = [ + { + provider: "generic", + url: resolveMockUpdateServerUrl(mockUpdateServerPort), + }, + ]; + } } if (platform === "mac") { + const path = yield* Path.Path; + const repoRoot = yield* RepoRoot; buildConfig.mac = { target: target === "dmg" ? [target, "zip"] : [target], icon: "icon.icns", @@ -2098,6 +2187,7 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* ( schemes: ["t3code", "t3code-dev"], }, ], + ...(signed ? { sign: path.join(repoRoot, "scripts/sign-macos.ts") } : {}), ...(macPasskeySigning ? { entitlements: macPasskeySigning.entitlementsPath, @@ -2220,8 +2310,7 @@ const stageWslNodePtyPrebuild = Effect.fn("stageWslNodePtyPrebuild")(function* ( return; } - // WSL runs the same CPU arch as the Windows host; universal is mac-only. - const linuxArch = input.arch === "x64" ? "x64" : input.arch === "arm64" ? "arm64" : undefined; + const linuxArch = resolveWslPrebuildArch(input.arch); if (linuxArch === undefined) { yield* Effect.logWarning( `[desktop-artifact] No WSL node-pty prebuild mapping for arch "${input.arch}"; skipping WSL backend bundling.`, @@ -2267,6 +2356,56 @@ const stageWslNodePtyPrebuild = Effect.fn("stageWslNodePtyPrebuild")(function* ( ); }); +// tar reads an `-f` target containing a colon as `host:path` and tries to reach +// it over rsh, so handing it a Windows drive path (C:\...\wsl-runtime.tar.gz) +// makes Git for Windows' GNU tar fail with "Cannot connect to C: resolve +// failed". The staged source tree and the archive both live under the build's +// stage root, so the target is always expressible relative to tar's cwd. +export const wslRuntimeArchiveTarTarget = (relativeArchivePath: string): string => + relativeArchivePath.replaceAll("\\", "/"); + +// `archivePath` is relative to the cwd tar runs in; see wslRuntimeArchiveTarTarget. +export const buildWslRuntimeArchiveArgs = ( + archivePath: string = WSL_RUNTIME_ARCHIVE_EXTRA_RESOURCE.from, +): ReadonlyArray => [ + "-czf", + archivePath, + ...WSL_RUNTIME_ARCHIVE_EXCLUDED_PREFIXES.map((prefix) => `--exclude=${prefix}*`), + ...WSL_RUNTIME_ARCHIVE_CONTENT_ROOTS, +]; + +export const parseWslRuntimeArchiveMembers = (listing: string): ReadonlyArray => + listing + .split(/\r?\n/) + .map((member) => member.replace(/^\.\//, "").replace(/\/$/, "")) + .filter((member) => member.length > 0); + +export const stageWslRuntimeArchive = Effect.fn("stageWslRuntimeArchive")(function* (input: { + readonly sourceDir: string; + readonly archivePath: string; + readonly hashPath: string; +}) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.makeDirectory(path.dirname(input.archivePath), { recursive: true }); + const tarTarget = wslRuntimeArchiveTarTarget(path.relative(input.sourceDir, input.archivePath)); + yield* runCommand( + ChildProcess.make("tar", buildWslRuntimeArchiveArgs(tarTarget), { + cwd: input.sourceDir, + }), + { label: "tar WSL runtime", verbose: false }, + ); + const hash = NodeCrypto.createHash("sha256"); + yield* fs + .stream(input.archivePath) + .pipe(Stream.runForEach((chunk) => Effect.sync(() => hash.update(chunk)))); + const digest = hash.digest("hex"); + yield* fs.writeFileString(input.hashPath, `${digest}\n`); + yield* Effect.log( + `[desktop-artifact] Staged compressed WSL runtime at ${input.archivePath} (${digest}).`, + ); +}); + // Stage and pack the Windows server sidecar: the bundled server plus a hoisted // install of only its runtime-external/native dependency closure for win32 and // WSL Linux. The Windows primary runs from the archive through the asar-aware @@ -2310,6 +2449,8 @@ export const stageWindowsServerSidecar = Effect.fn("stageWindowsServerSidecar")( readonly overrides: Record; readonly wslPrebuildPath: string | undefined; readonly asarPath: string; + readonly wslRuntimeArchivePath: string; + readonly wslRuntimeArchiveHashPath: string; readonly verbose: boolean; }) { const fs = yield* FileSystem.FileSystem; @@ -2375,6 +2516,16 @@ export const stageWindowsServerSidecar = Effect.fn("stageWindowsServerSidecar")( arch: input.arch, prebuildPath: input.wslPrebuildPath, }); + // Skip the archive entirely rather than shipping one the install script must + // extract and reject on every launch. The desktop app treats a missing + // archive as "no WSL-local runtime" and goes straight to the mounted tree. + if (bundlesWslRuntime({ arch: input.arch, prebuildPath: input.wslPrebuildPath })) { + yield* stageWslRuntimeArchive({ + sourceDir: serverStageDir, + archivePath: input.wslRuntimeArchivePath, + hashPath: input.wslRuntimeArchiveHashPath, + }); + } yield* Effect.log("[desktop-artifact] Packing server.asar..."); yield* fs.makeDirectory(path.dirname(input.asarPath), { recursive: true }); @@ -2519,6 +2670,7 @@ export const validateWindowsPackagedPayload = Effect.fn( readonly stageDistDir: string; readonly appExecutableName: string; readonly targetArch: typeof BuildArch.Type; + readonly expectWslRuntime?: boolean; readonly fileLimit?: number; readonly verbose?: boolean; }) { @@ -2618,6 +2770,99 @@ export const validateWindowsPackagedPayload = Effect.fn( }); } + const wslArchivePath = path.join(resourcesDir, WSL_RUNTIME_ARCHIVE_NAME); + const wslArchiveHashPath = path.join(resourcesDir, WSL_RUNTIME_ARCHIVE_HASH_NAME); + const [hasWslArchive, hasWslArchiveHash] = yield* Effect.all([ + isFile(wslArchivePath), + isFile(wslArchiveHashPath), + ]); + if (input.expectWslRuntime === true && (!hasWslArchive || !hasWslArchiveHash)) { + return yield* new WindowsPackagedPayloadValidationError({ + reason: "wsl-runtime-missing", + packagedAppDir, + missingFiles: [ + ...(hasWslArchive ? [] : [WSL_RUNTIME_ARCHIVE_NAME]), + ...(hasWslArchiveHash ? [] : [WSL_RUNTIME_ARCHIVE_HASH_NAME]), + ], + }); + } + if (hasWslArchive !== hasWslArchiveHash) { + return yield* new WindowsPackagedPayloadValidationError({ + reason: "wsl-runtime-missing", + packagedAppDir, + missingFiles: [hasWslArchive ? WSL_RUNTIME_ARCHIVE_HASH_NAME : WSL_RUNTIME_ARCHIVE_NAME], + }); + } + if (hasWslArchive && hasWslArchiveHash) { + const invalidWslRuntime = (cause: unknown) => + new WindowsPackagedPayloadValidationError({ + reason: "wsl-runtime-invalid", + packagedAppDir, + cause, + }); + const recordedHash = yield* fs + .readFileString(wslArchiveHashPath) + .pipe(Effect.mapError(invalidWslRuntime)); + const expectedHash = recordedHash.trim().toLowerCase(); + if (!/^[0-9a-f]{64}$/.test(expectedHash)) { + return yield* invalidWslRuntime(new Error("invalid WSL runtime SHA-256 sidecar")); + } + const archiveHash = NodeCrypto.createHash("sha256"); + yield* fs.stream(wslArchivePath).pipe( + Stream.runForEach((chunk) => Effect.sync(() => archiveHash.update(chunk))), + Effect.mapError(invalidWslRuntime), + ); + const actualHash = archiveHash.digest("hex"); + if (actualHash !== expectedHash) { + return yield* invalidWslRuntime( + new Error(`WSL runtime SHA-256 mismatch: expected ${expectedHash}, got ${actualHash}`), + ); + } + + const listing = yield* spawnAndCollectOutput( + ChildProcess.make("tar", ["-tzf", WSL_RUNTIME_ARCHIVE_NAME], { + cwd: resourcesDir, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }), + ).pipe(Effect.mapError(invalidWslRuntime)); + if (listing.exitCode !== 0) { + return yield* invalidWslRuntime( + new Error(`tar could not list WSL runtime archive: ${listing.stderr.trim()}`), + ); + } + const members = parseWslRuntimeArchiveMembers(listing.stdout); + const forbiddenMember = members.find((member) => + WSL_RUNTIME_ARCHIVE_EXCLUDED_PREFIXES.some((prefix) => member.startsWith(prefix)), + ); + if (forbiddenMember !== undefined) { + return yield* invalidWslRuntime( + new Error(`WSL runtime archive contains forbidden member ${forbiddenMember}`), + ); + } + const wslArch = resolveWslPrebuildArch(input.targetArch); + const requiredMembers = [ + "apps/server/dist/bin.mjs", + "node_modules/node-pty/package.json", + ...(wslArch === undefined + ? [] + : [ + `node_modules/node-pty/prebuilds/linux-${wslArch}/pty.node`, + `node_modules/node-pty/prebuilds/linux-${wslArch}/t3code-wsl-node-pty.json`, + ]), + ]; + const missingMembers = requiredMembers.filter((member) => !members.includes(member)); + if (missingMembers.length > 0) { + return yield* new WindowsPackagedPayloadValidationError({ + reason: "wsl-runtime-invalid", + packagedAppDir, + missingFiles: missingMembers, + cause: new Error("WSL runtime archive is incomplete"), + }); + } + } + const fileCount = yield* countPayloadFiles(packagedAppDir); if (fileCount > fileLimit) { return yield* new WindowsPackagedPayloadValidationError({ @@ -2899,21 +3144,28 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( // Windows splits dependencies per process: app.asar carries only the // desktop main-process runtime deps, while the server bundle's deps live in - // the server.asar sidecar (see stageWindowsServerSidecar). macOS and Linux - // keep the single merged tree — their primary resolves everything from - // app.asar and there is no second consumer. + // the server.asar sidecar (see stageWindowsServerSidecar). macOS adds only + // server packages that remain external to its merged app.asar. Linux retains + // its existing full dependency tree. const stageDependencies = options.platform === "win" ? { ...resolvedDesktopRuntimeDependencies } - : { - ...resolvedServerDependencies, - ...resolvedDesktopRuntimeDependencies, - ...resolveFffNativeDependencies( - options.platform, - options.arch, - serverPackageJson.dependencies["@ff-labs/fff-node"], - ), - }; + : options.platform === "mac" + ? resolveMacStageDependencies({ + serverDependencies: resolvedServerDependencies, + desktopDependencies: resolvedDesktopRuntimeDependencies, + arch: options.arch, + fffNodeVersion: serverPackageJson.dependencies["@ff-labs/fff-node"], + }) + : { + ...resolvedServerDependencies, + ...resolvedDesktopRuntimeDependencies, + ...resolveFffNativeDependencies( + options.platform, + options.arch, + serverPackageJson.dependencies["@ff-labs/fff-node"], + ), + }; const stagePatchedDependencies = createStagePatchedDependencies( workspacePatchedDependencies, stageDependencies, @@ -2945,6 +3197,7 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( provisioningProfilePath: macPasskeySigning.provisioningProfilePath, } : undefined, + bundlesWslRuntime({ arch: options.arch, prebuildPath: options.wslPrebuild }), ), dependencies: stageDependencies, devDependencies: { @@ -2999,6 +3252,12 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( overrides: resolvedOverrides, wslPrebuildPath: options.wslPrebuild, asarPath: windowsServerAsarPath, + wslRuntimeArchivePath: path.join(stageAppDir, WSL_RUNTIME_ARCHIVE_EXTRA_RESOURCE.from), + wslRuntimeArchiveHashPath: path.join( + stageAppDir, + WSL_RUNTIME_ARCHIVE_HASH_EXTRA_RESOURCE.from, + ), + verbose: options.verbose, }); } @@ -3034,10 +3293,12 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( buildEnv.GYP_MSVS_VERSION = buildEnv.GYP_MSVS_VERSION ?? "2022"; } if (options.verbose) { - buildEnv.DEBUG = - buildEnv.DEBUG === undefined - ? "electron-builder,electron-builder:*" - : `${buildEnv.DEBUG},electron-builder,electron-builder:*`; + const debugNamespaces = [ + "electron-builder", + "electron-builder:*", + ...(options.platform === "mac" ? ["electron-osx-sign*", "electron-notarize*"] : []), + ]; + buildEnv.DEBUG = [buildEnv.DEBUG, ...debugNamespaces].filter(Boolean).join(","); } yield* Effect.log( @@ -3095,6 +3356,10 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( stageDistDir, appExecutableName: `${resolveDesktopProductName(appVersion)}.exe`, targetArch: options.arch, + expectWslRuntime: bundlesWslRuntime({ + arch: options.arch, + prebuildPath: options.wslPrebuild, + }), verbose: options.verbose, }); } diff --git a/scripts/lib/resolve-catalog.test.ts b/scripts/lib/resolve-catalog.test.ts deleted file mode 100644 index ae9a22911571..000000000000 --- a/scripts/lib/resolve-catalog.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { assert, it } from "@effect/vitest"; - -import { CatalogDependencyResolutionError, resolveCatalogDependencies } from "./resolve-catalog.ts"; - -it("reports unresolved catalog dependencies with lookup context", () => { - try { - resolveCatalogDependencies({ effect: "catalog:runtime" }, {}, "apps/server"); - assert.fail("Expected catalog resolution to fail."); - } catch (error) { - assert.instanceOf(error, CatalogDependencyResolutionError); - assert.equal(error.workspacePackage, "apps/server"); - assert.equal(error.dependencyName, "effect"); - assert.equal(error.catalogSpec, "catalog:runtime"); - assert.equal(error.catalogKey, "runtime"); - assert.equal( - error.message, - "Unable to resolve 'catalog:runtime' for apps/server dependency 'effect'. Expected key 'runtime' in root workspace catalog.", - ); - } -}); diff --git a/scripts/lib/resolve-catalog.ts b/scripts/lib/resolve-catalog.ts index eb9d4cc78c8a..0380cf48efb1 100644 --- a/scripts/lib/resolve-catalog.ts +++ b/scripts/lib/resolve-catalog.ts @@ -1,6 +1,6 @@ import * as Schema from "effect/Schema"; -export class CatalogDependencyResolutionError extends Schema.TaggedErrorClass()( +class CatalogDependencyResolutionError extends Schema.TaggedErrorClass()( "CatalogDependencyResolutionError", { workspacePackage: Schema.String, diff --git a/scripts/package.json b/scripts/package.json index 14c4ea98e9b2..042d69a8d9f9 100644 --- a/scripts/package.json +++ b/scripts/package.json @@ -9,12 +9,11 @@ "dependencies": { "@effect/platform-node": "catalog:", "@electron/asar": "^3.4.1", - "@t3tools/contracts": "workspace:*", + "@electron/osx-sign": "2.7.0", "@t3tools/shared": "workspace:*", "@t3tools/tailscale": "workspace:*", "effect": "catalog:", - "pngjs": "7.0.0", - "yaml": "catalog:" + "pngjs": "7.0.0" }, "devDependencies": { "@effect/vitest": "catalog:", diff --git a/scripts/sign-macos.test.ts b/scripts/sign-macos.test.ts new file mode 100644 index 000000000000..fd8d365c90db --- /dev/null +++ b/scripts/sign-macos.test.ts @@ -0,0 +1,26 @@ +import { sign as signApplication, type SignOptions } from "@electron/osx-sign"; +import { expect, it, vi } from "vite-plus/test"; + +import sign from "./sign-macos.ts"; + +vi.mock("@electron/osx-sign", () => ({ sign: vi.fn() })); + +it("batches codesign calls without changing existing signing options", async () => { + const options = { + app: "/tmp/T3 Code.app", + identity: "Developer ID Application: T3 Tools, Inc.", + keychain: "/tmp/t3code.keychain", + provisioningProfile: "/tmp/t3code.provisionprofile", + optionsForFile: () => ({ + entitlements: "/tmp/t3code.entitlements.plist", + hardenedRuntime: true, + }), + } satisfies SignOptions; + + await sign(options); + + expect(signApplication).toHaveBeenCalledExactlyOnceWith({ + ...options, + batchCodesignCalls: true, + }); +}); diff --git a/scripts/sign-macos.ts b/scripts/sign-macos.ts new file mode 100644 index 000000000000..3686db6aaf85 --- /dev/null +++ b/scripts/sign-macos.ts @@ -0,0 +1,6 @@ +import { sign as signApplication, type SignOptions } from "@electron/osx-sign"; + +/** Sign files with matching options together instead of spawning codesign for each file. */ +export default async function sign(options: SignOptions): Promise { + await signApplication({ ...options, batchCodesignCalls: true }); +} diff --git a/t3.json b/t3.json index 007e8f961948..284d416a6820 100644 --- a/t3.json +++ b/t3.json @@ -7,6 +7,12 @@ "command": "vp i && ln -sf $T3CODE_PROJECT_ROOT/.env .env && ln -sf $T3CODE_PROJECT_ROOT/infra/relay/.env infra/relay/.env && node apps/web/scripts/warm-dep-cache.ts", "icon": "configure", "runOnWorktreeCreate": true + }, + { + "name": "Setup Worktree (Windows)", + "command": "vp i && New-Item -ItemType SymbolicLink -Path .env -Target \"$env:T3CODE_PROJECT_ROOT\\.env\" -Force && New-Item -ItemType SymbolicLink -Path \"infra\\relay\\.env\" -Target \"$env:T3CODE_PROJECT_ROOT\\infra\\relay\\.env\" -Force && node apps\\web\\scripts\\warm-dep-cache.ts", + "icon": "configure", + "runOnWorktreeCreate": true } ] } diff --git a/vite.config.ts b/vite.config.ts index 7e45ea33f2ae..c5ceb42ed2ea 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -22,7 +22,7 @@ export default defineConfig({ }, staged: { // Formatter only for now — no lint or typecheck on commit. - "*": "vp fmt", + "*": "vp fmt --no-error-on-unmatched-pattern", }, fmt: { ignorePatterns: [ @@ -120,6 +120,7 @@ export default defineConfig({ "t3code/no-global-process-runtime": "error", "t3code/no-inline-schema-compile": "warn", "t3code/no-manual-effect-runtime-in-tests": "error", + "t3code/no-mobile-uniwind-theme-escape-hatches": "error", "t3code/no-native-title-tooltip": "error", "t3code/namespace-node-imports": "error", },