diff --git a/.agents/skills/test-t3-mobile/SKILL.md b/.agents/skills/test-t3-mobile/SKILL.md index 587b9b120b8b..58bfa536f88a 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..75cec0ff9c6b 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -48,6 +48,7 @@ github:PollyGlot github:RakshithBhat03 github:realAhmedRoach github:Rishet11 +github:ryanrhughes github:saphid github:sethwebster github:shiroyasha9 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a0b71ef30341..8b25d0a7c405 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 93b6da6ae61c..2199a84d2b2e 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/ @@ -760,9 +782,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/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 da7748ee58cd..10ab69b26245 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.37", "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..7a696f20886d --- /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 marcode/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/backend/DesktopBackendConfiguration.test.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts index 2bfb5af52a19..81607ccc960a 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 de86165cb0f3..4a87ab7d5fa4 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 5357491946b7..cedb02d81ba5 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 1208a9b4723d..563e3fbc7c34 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 e265178bf643..2cf8aea15848 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 d3d7623b43bd..f556501e405a 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'", "marcode:", "blob:"]); assert.deepEqual(directives["font-src"], ["'self'", "marcode:", "data:"]); }); }); diff --git a/apps/desktop/src/electron/ElectronProtocol.ts b/apps/desktop/src/electron/ElectronProtocol.ts index 350750b2c711..fb17124210a2 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:`, "style-src 'self' 'unsafe-inline'", `font-src 'self' ${input.scheme}: data:`, "worker-src 'self' blob:", 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..718ebd2a3cf3 100644 --- a/apps/desktop/src/ipc/methods/preview.ts +++ b/apps/desktop/src/ipc/methods/preview.ts @@ -5,11 +5,13 @@ import { DesktopPreviewAutomationEvaluateInputSchema, DesktopPreviewAutomationPressInputSchema, DesktopPreviewAutomationScrollInputSchema, + DesktopPreviewAutomationStatusSchema, DesktopPreviewAutomationTypeInputSchema, DesktopPreviewAutomationWaitForInputSchema, DesktopPreviewConfigInputSchema, DesktopPreviewNavigateInputSchema, DesktopPreviewRecordingArtifactSchema, + DesktopPreviewRecordingSourceSchema, DesktopPreviewRecordingSaveInputSchema, DesktopPreviewRegisterWebviewInputSchema, DesktopPreviewScreenshotArtifactSchema, @@ -20,7 +22,6 @@ import { DesktopPreviewWebviewConfigSchema, PreviewAnnotationSubmissionResultSchema, PreviewAutomationSnapshot, - PreviewAutomationStatus, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; @@ -173,11 +174,15 @@ export const cancelPickElement = tabMethod( "desktop.ipc.preview.cancelPickElement", (manager, tabId) => manager.cancelPickElement(tabId), ); -export const startRecording = tabMethod( - IpcChannels.PREVIEW_RECORDING_START_CHANNEL, - "desktop.ipc.preview.startRecording", - (manager, tabId) => manager.startRecording(tabId), -); +export const startRecording = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.PREVIEW_RECORDING_START_CHANNEL, + payload: DesktopPreviewTabInputSchema, + result: DesktopPreviewRecordingSourceSchema, + handler: Effect.fn("desktop.ipc.preview.startRecording")(function* ({ tabId }) { + const manager = yield* PreviewManager.PreviewManager; + return yield* manager.startRecording(tabId); + }), +}); export const stopRecording = tabMethod( IpcChannels.PREVIEW_RECORDING_STOP_CHANNEL, "desktop.ipc.preview.stopRecording", @@ -282,7 +287,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/preload.ts b/apps/desktop/src/preload.ts index 407c7c3ef498..ac9ab8668baa 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 marcode/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; diff --git a/apps/desktop/src/preview/BrowserSession.test.ts b/apps/desktop/src/preview/BrowserSession.test.ts index 12ca41a0e569..30fae6d53a75 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 059bf74aaa72..38f1a75d0358 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 87e0d5b0fe59..6bf56d0bf09a 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -36,6 +36,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 +66,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, @@ -163,6 +215,9 @@ const makeTestPreviewWebContents = ( ) => ({ id, + hostWebContents: { id: 7 }, + getMediaSourceId: vi.fn(() => `tab:${id}`), + executeJavaScript: vi.fn(async () => ({ width: 1280, height: 720 })), isDestroyed: () => false, getType: () => "webview", getURL: () => "https://example.com", @@ -288,8 +343,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 +374,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 +1677,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({ @@ -2007,7 +2075,7 @@ describe("PreviewManager", () => { ), ); - effectIt.effect("captures hidden preview recordings independently for concurrent tabs", () => + effectIt.effect("returns native media sources for concurrent preview recordings", () => withManager((manager) => Effect.gen(function* () { const firstJpeg = Buffer.from("first-recording-frame"); @@ -2029,6 +2097,11 @@ describe("PreviewManager", () => { ) => ({ id, + hostWebContents: { id: 7 }, + getMediaSourceId: vi.fn(() => `tab:${id}`), + executeJavaScript: vi.fn(async () => + id === 41 ? { width: 800, height: 600 } : { width: 390, height: 844 }, + ), isDestroyed: () => false, getType: () => "webview", getURL: () => `https://example.com/${id}`, @@ -2060,41 +2133,21 @@ 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 sources = yield* Effect.all( + [manager.startRecording("tab_1"), manager.startRecording("tab_2")], + { concurrency: 2 }, + ); + expect(sources).toEqual([ + { sourceId: "tab:41", width: 800, height: 600 }, + { sourceId: "tab:42", width: 390, height: 844 }, + ]); 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 +2165,178 @@ describe("PreviewManager", () => { ), ); - effectIt.effect("drops a captured frame when the tab webview changes during capture", () => + effectIt.effect("continues native recording when the source warmup fails", () => 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 capturePage = vi.fn(async () => { + throw new Error("source is not ready"); }); - 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; + const getMediaSourceId = vi.fn(() => "tab:42"); + const webContents = Object.assign(makeTestPreviewWebContents(capturePage), { + executeJavaScript: vi.fn(async () => ({ width: 1280, height: 720 })), + getMediaSourceId, }); - const frames: DesktopPreviewRecordingFrame[] = []; + fromId.mockReturnValue(webContents); - 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); + yield* manager.createTab("tab_recording_warmup_failure"); + yield* manager.registerWebview("tab_recording_warmup_failure", 42); - yield* manager.registerWebview("tab_capture_replaced", 43); - resolveCapture?.(staleImage); - yield* Fiber.join(recordingFiber); - - expect(staleImage.getSize).not.toHaveBeenCalled(); - expect(staleImage.toJPEG).not.toHaveBeenCalled(); - expect(frames).toHaveLength(0); - expect(replacementCapturePage).not.toHaveBeenCalled(); + expect(yield* manager.startRecording("tab_recording_warmup_failure")).toEqual({ + sourceId: "tab:42", + width: 1280, + height: 720, + }); + expect(capturePage).toHaveBeenCalledTimes(2); + expect(getMediaSourceId).toHaveBeenCalledOnce(); - yield* manager.stopRecording("tab_capture_replaced"); + yield* manager.stopRecording("tab_recording_warmup_failure"); }), ), ); - effectIt.effect("keeps an in-flight frame when a capture consumer is added", () => + effectIt.effect("reports invalid native recording dimensions as a structured error", () => 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; - }); - }); - fromId.mockReturnValue(makeTestPreviewWebContents(capturePage)); - const { pictureInPictureWindow, send } = makeTestPictureInPictureWindow(); - browserWindowConstructor.mockImplementation(function () { - return pictureInPictureWindow; + const capturePage = vi.fn(async () => ({ + toJPEG: () => Buffer.from("unused-recording-frame"), + getSize: () => ({ width: 1280, height: 720 }), + })); + const getMediaSourceId = vi.fn(() => "tab:42"); + const webContents = Object.assign(makeTestPreviewWebContents(capturePage), { + executeJavaScript: vi.fn(async () => ({ width: 0, height: 720 })), + getMediaSourceId, }); - const recordingFrames: DesktopPreviewRecordingFrame[] = []; - yield* manager.subscribeRecordingFrames((frame) => - Effect.sync(() => { - recordingFrames.push(frame); - }), - ); - - 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.openPictureInPicture("tab_capture_consumer_added"); - resolveCapture?.(image); - yield* Fiber.join(recordingFiber); + fromId.mockReturnValue(webContents); - 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"), - }), - ); + yield* manager.createTab("tab_invalid_recording_size"); + yield* manager.registerWebview("tab_invalid_recording_size", 42); + const exit = yield* Effect.exit(manager.startRecording("tab_invalid_recording_size")); - yield* manager.stopRecording("tab_capture_consumer_added"); - yield* manager.closePictureInPicture("tab_capture_consumer_added"); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isSuccess(exit)) return; + expect(Option.getOrThrow(Cause.findErrorOption(exit.cause))).toMatchObject({ + _tag: "PreviewRecordingSourceSizeUnavailableError", + tabId: "tab_invalid_recording_size", + webContentsId: 42, + }); + expect(getMediaSourceId).not.toHaveBeenCalled(); }), ), ); - effectIt.effect("emits debugger screencast frames only while recording is active", () => + effectIt.effect("keeps a newer recording lease when an earlier start fails", () => withManager((manager) => Effect.gen(function* () { - let debuggerMessage: - | ((event: unknown, method: string, params: Record) => void) - | undefined; + const setBackgroundThrottling = vi.fn(); const capturePage = vi.fn(async () => ({ - toJPEG: () => Buffer.from("scheduled-recording-frame"), + 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, + let markMeasurementStarted!: () => void; + const measurementStarted = new Promise((resolve) => { + markMeasurementStarted = resolve; + }); + let rejectFirstMeasurement!: (error: Error) => void; + const executeJavaScript = vi + .fn<(expression: string, userGesture?: boolean) => Promise>() + .mockImplementationOnce(() => { + markMeasurementStarted(); + return new Promise((_, reject) => { + rejectFirstMeasurement = reject; + }); + }) + .mockResolvedValue({ width: 1280, height: 720 }); + const webContents = Object.assign(makeTestPreviewWebContents(capturePage), { + executeJavaScript, + }); + fromId.mockReturnValue(webContents); + + yield* manager.createTab("tab_recording_start_race"); + yield* manager.registerWebview("tab_recording_start_race", 42); + yield* manager.setMainWindow({ 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, + once: vi.fn(), + webContents: { setBackgroundThrottling }, } 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 }, - }); + const firstStart = yield* manager + .startRecording("tab_recording_start_race") + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Effect.promise(() => measurementStarted); + const secondStart = yield* manager + .startRecording("tab_recording_start_race") + .pipe(Effect.forkChild({ startImmediately: true })); yield* Effect.yieldNow; - expect(recordingFrames).toHaveLength(0); + expect(executeJavaScript).toHaveBeenCalledOnce(); + + rejectFirstMeasurement(new Error("first measurement failed")); + const firstExit = yield* Fiber.await(firstStart); + expect(Exit.isFailure(firstExit)).toBe(true); + expect(yield* Fiber.join(secondStart)).toEqual({ + sourceId: "tab:42", + width: 1280, + height: 720, + }); + + yield* manager.stopRecording("tab_recording_start_race"); + expect(setBackgroundThrottling.mock.calls).toEqual([[false], [true], [false], [true]]); + }), + ), + ); - yield* manager.startRecording("tab_screencast_guard"); - recordingFrames.length = 0; - debuggerMessage?.({}, "Page.screencastFrame", { - sessionId: 2, - data: "active-frame", - metadata: { deviceWidth: 1280, deviceHeight: 720 }, + effectIt.effect("serializes recording source acquisition with webview replacement", () => + withManager((manager) => + Effect.gen(function* () { + const capturePage = vi.fn(async () => ({ + toJPEG: () => Buffer.from("unused-recording-frame"), + getSize: () => ({ width: 1280, height: 720 }), + })); + let markMeasurementStarted!: () => void; + const measurementStarted = new Promise((resolve) => { + markMeasurementStarted = resolve; + }); + let finishMeasurement!: (size: { readonly width: number; readonly height: number }) => void; + const executeJavaScript = vi.fn( + () => + new Promise<{ readonly width: number; readonly height: number }>((resolve) => { + markMeasurementStarted(); + finishMeasurement = resolve; + }), + ); + const initialWebContents = Object.assign(makeTestPreviewWebContents(capturePage, 42), { + executeJavaScript, + }); + 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* 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(() => measurementStarted); + const replacement = yield* manager + .registerWebview("tab_recording_replacement_race", 43) + .pipe(Effect.forkChild({ startImmediately: true })); yield* Effect.yieldNow; + expect(replacementOn).not.toHaveBeenCalled(); - expect(recordingFrames).toEqual([ - expect.objectContaining({ - tabId: "tab_screencast_guard", - data: "active-frame", - width: 1280, - height: 720, - }), - ]); - yield* manager.stopRecording("tab_screencast_guard"); + finishMeasurement({ width: 1280, height: 720 }); + expect(yield* Fiber.join(start)).toEqual({ + sourceId: "tab:42", + width: 1280, + height: 720, + }); + yield* Fiber.join(replacement); + expect(replacementOn).toHaveBeenCalled(); + yield* manager.stopRecording("tab_recording_replacement_race"); }), ), ); @@ -2326,6 +2354,8 @@ describe("PreviewManager", () => { fromId.mockReturnValue({ id: 42, hostWebContents: mainWindowWebContents, + getMediaSourceId: vi.fn(() => "tab:42"), + executeJavaScript: vi.fn(async () => ({ width: 1280, height: 720 })), isDestroyed: () => false, getType: () => "webview", getURL: () => "https://example.com", @@ -2369,6 +2399,8 @@ describe("PreviewManager", () => { pictureInPictureListeners.get("closed")?.(); }), webContents: { + on: vi.fn(), + off: vi.fn(), send: pictureInPictureSend, }, }; @@ -2434,24 +2466,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 +2499,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..7d11b2614c1e 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -7,6 +7,7 @@ */ import type { DesktopPreviewAnnotationTheme, + DesktopPreviewAutomationStatus, DesktopPreviewColorScheme, DesktopPreviewFavicon, DesktopPreviewPointerEvent, @@ -15,6 +16,7 @@ import type { PreviewAnnotationSubmissionResult, DesktopPreviewRecordingArtifact, DesktopPreviewRecordingFrame, + DesktopPreviewRecordingSource, DesktopPreviewScreenshotArtifact, DesktopPreviewTabDefaults, PreviewAutomationClickInput, @@ -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; +const RECORDING_SOURCE_SIZE_EXPRESSION = + "({ width: Math.round(globalThis.innerWidth), height: Math.round(globalThis.innerHeight) })"; +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; @@ -188,6 +191,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 +388,9 @@ interface ManagedListeners { type FrameCaptureConsumer = "picture-in-picture" | "recording"; interface FrameCaptureSession { - readonly scope: Scope.Closeable; + readonly scope: Scope.Closeable | null; readonly consumers: ReadonlySet; + readonly lastPictureInPictureFrame: Buffer | null; } interface PictureInPictureSession { @@ -434,6 +444,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" && @@ -614,9 +679,15 @@ 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; } @@ -1661,6 +1732,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 +1763,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 +1782,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) => @@ -2503,7 +2585,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 +2632,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 +2699,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 +2726,10 @@ 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. + // Recording keeps only the activity lease. Picture-in-picture owns the + // capturePage loop and tolerates transient compositor warmup failures. yield* requireWebContents(tabId); - const captureNextFrame = Effect.sleep(RECORDING_FRAME_INTERVAL_MS).pipe( + 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 +2738,60 @@ 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 current = sessions.get(tabId); + if (current) { + if (current.consumers.has(consumer)) { + return [false, sessions] as const; + } + 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]), + }); + }), + ] as const; + } + if (sessions.size === 0) { + yield* setFrameCaptureBackgroundThrottling(false); + } + 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]), + 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 +2940,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 +2972,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function skipTransformProcessType: true, }); } + pipWebContents.on("did-finish-load", onDidFinishLoad); }, ).pipe( Effect.onError(() => @@ -2875,6 +2987,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); @@ -2982,11 +3100,77 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }); 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 }); + } + const measuredSize = yield* attemptPromise( + { + operation: "recording.measureSource", + tabId, + webContentsId: wc.id, + }, + () => wc.executeJavaScript(RECORDING_SOURCE_SIZE_EXPRESSION, true), + ); + if ( + typeof measuredSize !== "object" || + measuredSize === null || + !("width" in measuredSize) || + !("height" in measuredSize) || + typeof measuredSize.width !== "number" || + typeof measuredSize.height !== "number" || + !Number.isInteger(measuredSize.width) || + !Number.isInteger(measuredSize.height) || + measuredSize.width <= 0 || + measuredSize.height <= 0 + ) { + return yield* new PreviewRecordingSourceSizeUnavailableError({ + tabId, + webContentsId: wc.id, + }); + } + 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, + }); + } + const sourceId = yield* attempt( + { + operation: "recording.getMediaSourceId", + tabId, + webContentsId: wc.id, + }, + () => wc.getMediaSourceId(requestWebContents), + ); + return { + sourceId, + width: measuredSize.width, + height: measuredSize.height, + } satisfies DesktopPreviewRecordingSource; + }).pipe(Effect.onError(() => stopFrameCapture(tabId, "recording").pipe(Effect.ignore))), + ); }); const stopRecording = Effect.fn("PreviewManager.stopRecording")(function* (tabId: string) { - yield* stopFrameCapture(tabId, "recording"); + yield* withTabLifecycleLock(tabId, stopFrameCapture(tabId, "recording")); }); const saveRecording = Effect.fn("PreviewManager.saveRecording")(function* ( @@ -2996,7 +3180,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 +3980,15 @@ export class PreviewMainWindowClosedError extends Schema.TaggedErrorClass()( + "PreviewRecordingSourceSizeUnavailableError", + { tabId: Schema.String, webContentsId: Schema.Number }, +) { + override get message(): string { + return `Preview media source dimensions are unavailable for tab ${this.tabId}`; + } +} + export class PreviewOperationError extends Schema.TaggedErrorClass()( "PreviewOperationError", { @@ -4003,6 +4196,7 @@ export const PreviewManagerError = Schema.Union([ PreviewWebContentsNotFoundError, PreviewWebviewNotInitializedError, PreviewMainWindowClosedError, + PreviewRecordingSourceSizeUnavailableError, PreviewOperationError, PreviewArtifactPathOutsideDirectoryError, PreviewArtifactImageLoadError, @@ -4080,7 +4274,9 @@ export class PreviewManager extends Context.Service< readonly copyArtifactToClipboard: (path: string) => Effect.Effect; readonly openPictureInPicture: (tabId: string) => Effect.Effect; readonly closePictureInPicture: (tabId: string) => Effect.Effect; - readonly startRecording: (tabId: string) => Effect.Effect; + readonly startRecording: ( + tabId: string, + ) => Effect.Effect; readonly stopRecording: (tabId: string) => Effect.Effect; readonly saveRecording: ( tabId: string, @@ -4089,7 +4285,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/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 5376281a75a7..f5fdc4e4b4c4 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, 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 8d7d0afd46c4..12887daa6ff7 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 783d541101f1..311333c9849c 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/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 f6b5a779cfeb..155e803ee371 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 587f15679025..4184b13d4f01 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 c49d769dfe5f..84c3438623b7 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 `MARCODE_CLERK_PUBLISHABLE_KEY`, `MARCODE_CLERK_JWT_TEMPLATE`, and `MARCODE_RELAY_URL` diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 1cdbec3262cd..45a0829fa66c 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.MARCODE_IOS_PERSONAL_TEAM === "1"; +const runtimeVersionPolicy = + process.env.MOBILE_VERSION_POLICY ?? + (APP_VARIANT === "development" ? "appVersion" : "fingerprint"); const personalTeamBundleIdentifier = repoEnv.MARCODE_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,6 +58,8 @@ const RELEASE_ASSETS = { appIcon: fromRepoRoot(BRAND_ASSET_PATHS.productionIosIconPng), iosIcon: fromRepoRoot(BRAND_ASSET_PATHS.productionIconComposerProject), splashIcon: fromRepoRoot(BRAND_ASSET_PATHS.productionIosIconPng), + // ── Marcode fork seam ── the production mark is black on white, so the + // release adaptive icon keeps Marcode's own foreground and background. androidAdaptiveForeground: fromRepoRoot(BRAND_ASSET_PATHS.productionLinuxIconPng), androidAdaptiveBackgroundColor: "#ffffff", androidMonochromeIcon: "./assets/android-icon-mark.png", @@ -142,12 +150,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 +173,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 +208,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 +299,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/src/markdownLinks.ts b/apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts index f13891e3ff80..20637c6ba0f4 100644 --- a/apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts +++ b/apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts @@ -3,8 +3,10 @@ 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/", 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 136e5626c7cb..e4bbb6dcde7e 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 marcode-dev --clear --lan", - "dev:client:preview": "eas env:exec preview 'EXPO_NO_DOTENV=1 APP_VARIANT=preview expo start --dev-client --scheme marcode-preview --clear --lan'", + "dev": "expo start", + "dev:client": "APP_VARIANT=development expo start --dev-client --scheme marcode-dev --lan", + "dev:client:reset": "APP_VARIANT=development expo start --dev-client --scheme marcode-dev --clear --lan", + "dev:client:preview": "eas env:exec preview 'EXPO_NO_DOTENV=1 APP_VARIANT=preview expo start --dev-client --scheme marcode-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 marcode --clear", + "showcase": "APP_VARIANT=production EXPO_PUBLIC_SHOWCASE=1 expo start --dev-client --scheme marcode", "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,64 @@ "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", + "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", "punycode": "^2.3.1", "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 +138,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 2218db4c5f65..63b59d0e9a6d 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 db8faa6ac3e7..d7ca6d5cfcfa 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 20581771b05b..241b3c47a479 100644 --- a/apps/mobile/src/components/CompactBrandTitle.tsx +++ b/apps/mobile/src/components/CompactBrandTitle.tsx @@ -1,46 +1,37 @@ 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 { MarcodeMark } from "./MarcodeMark"; 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; } /** * Compact brand lockup sized for native navigation bars. + * + * ── Marcode fork seam ── + * Upstream renders its own wordmark and always shows a stage pill. Marcode + * substitutes `MarcodeMark` and hides the pill on the production channel, + * where `resolveMobileStageLabel` returns null. Everything else follows + * upstream, including the Uniwind semantic classes. */ export function CompactBrandTitle( props: { readonly allowFontScaling?: boolean; - readonly nativeLeadingItem?: boolean; } = {}, ) { - 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 ? ( - + {stageLabel} @@ -98,31 +67,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..c7740104fe6a --- /dev/null +++ b/apps/mobile/src/components/FilePreview.ios.tsx @@ -0,0 +1,43 @@ +import { requireNativeModule } from "expo"; +import { useEffect, useEffectEvent, useId } from "react"; +import { Alert } from "react-native"; + +import type { ResolvedFilePreviewSource } from "./FilePreviewModal"; + +const NativeControls = requireNativeModule<{ + presentFile( + uri: string, + name: string, + sourceIdentifier: string, + identifier: string, + ): Promise; + dismissFile(identifier: string): Promise; +}>("T3NativeControls"); + +export function FilePreview(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; +} diff --git a/apps/mobile/src/components/FilePreview.tsx b/apps/mobile/src/components/FilePreview.tsx new file mode 100644 index 000000000000..f10bfb8b3e63 --- /dev/null +++ b/apps/mobile/src/components/FilePreview.tsx @@ -0,0 +1,52 @@ +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"; + +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 ; + return ( + + ); +} diff --git a/apps/mobile/src/components/FilePreviewModal.tsx b/apps/mobile/src/components/FilePreviewModal.tsx new file mode 100644 index 000000000000..c9df7e892c27 --- /dev/null +++ b/apps/mobile/src/components/FilePreviewModal.tsx @@ -0,0 +1,93 @@ +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 { 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; +} + +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); + }, [uri, asset]); + useEffect(() => { + if (!failed) return; + Alert.alert("Could not open preview", "Reconnect to this environment and try again."); + onRequestClose(); + }, [failed]); + 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 index 8f91d61031bc..16a5fc4a479e 100644 --- a/apps/mobile/src/components/GlassSafeAreaView.tsx +++ b/apps/mobile/src/components/GlassSafeAreaView.tsx @@ -1,7 +1,6 @@ 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"; @@ -19,23 +18,13 @@ export function GlassSafeAreaView({ 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 ( - + { +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/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/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..a947d8d2e51c --- /dev/null +++ b/apps/mobile/src/components/VideoPreviewModal.ios.tsx @@ -0,0 +1,121 @@ +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 { VideoPreviewSource } from "./VideoPreviewModal"; + +export type { VideoPreviewSource } from "./VideoPreviewModal"; + +const NativeControls = requireNativeModule<{ + presentVideo( + uri: string, + title: string, + sourceIdentifier: string, + identifier: string, + ): Promise; + dismissVideo(identifier: string): Promise; +}>("T3NativeControls"); + +function NativeVideoPreview(props: { + readonly source: VideoPreviewSource; + 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; + return ; +} diff --git a/apps/mobile/src/components/VideoPreviewModal.tsx b/apps/mobile/src/components/VideoPreviewModal.tsx new file mode 100644 index 000000000000..eaa01c5d1714 --- /dev/null +++ b/apps/mobile/src/components/VideoPreviewModal.tsx @@ -0,0 +1,258 @@ +import { useIsFocused } from "@react-navigation/native"; +import type { ChatFileAttachment, EnvironmentId } from "@t3tools/contracts"; +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 type { DraftComposerFileAttachment } from "../lib/composerImages"; +import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview"; +import { useAssetUrlState } from "../state/assets"; +import { usePreparedConnection } from "../state/session"; +import { SymbolView } from "./AppSymbol"; +import { AppText } from "./AppText"; + +export type VideoPreviewSource = ( + | { readonly type: "local"; readonly attachment: DraftComposerFileAttachment } + | { + readonly type: "remote"; + readonly environmentId: EnvironmentId; + readonly attachment: ChatFileAttachment; + } +) & { readonly sourceIdentifier?: string }; + +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: VideoPreviewSource; + 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; + 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..0be94c700ce6 --- /dev/null +++ b/apps/mobile/src/components/VideoThumbnailImage.tsx @@ -0,0 +1,45 @@ +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; +}) { + 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 77c19e2381dc..005c48f4c848 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/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 930535ce0296..584ae06e302a 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/connection/CloudEnvironmentRows.tsx b/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx index d6baec12c040..aba340461df6 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) @@ -120,12 +118,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 ? ( - + ) : ( )} @@ -160,7 +158,7 @@ function CloudEnvironmentRowsContent( ) : controller.relayDiscovery.isRefreshing ? ( - + Loading linked cloud environments. @@ -277,7 +275,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; @@ -289,7 +286,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; @@ -379,7 +376,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 8cb86d26cb55..d5063bd77fc2 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/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..5dddac1dd820 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, 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"; @@ -11,15 +12,18 @@ import { ThreadId, } from "@t3tools/contracts"; -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 { useThreadSelection } from "../../state/use-thread-selection"; import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree"; import { useEnvironmentQuery } from "../../state/query"; @@ -135,11 +139,11 @@ function FileContent(props: { return ( {props.truncated ? ( - - + + Partial file - + Preview limited to the first 1 MB of a truncated file. @@ -210,7 +214,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 +249,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 +417,12 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) { ]} /> - + (null); const [previewRevision, setPreviewRevision] = useState(0); + const [fullScreenPreview, setFullScreenPreview] = useState(null); const isBrowserFile = relativePath !== null && isBrowserPreviewFile(relativePath); const isImageFile = relativePath !== null && isImagePreviewFile(relativePath); const canPreview = @@ -549,6 +560,113 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { ); useRegisterWorkspaceInspector(fileInspector.supported ? renderWorkspaceInspector : undefined); + const fileMenuActions = useMemo(() => { + if (relativePath === null) return []; + const canToggleMode = canPreview && !isImageFile; + 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, + { + 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) + ? ({ + id: "refresh", + title: "Refresh", + icon: "arrow.clockwise", + inline: false, + onPress: () => setPreviewRevision((current) => current + 1), + } as const) + : null, + ].filter((action) => action !== null); + }, [ + assetPreviewUri, + previewUri, + canPreview, + isBrowserFile, + isImageFile, + relativePath, + resolvedActiveMode, + ]); + + 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 +695,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 +703,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,50 +752,33 @@ 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..e725c4d133f6 100644 --- a/apps/mobile/src/features/files/WorkspaceFileImagePreview.tsx +++ b/apps/mobile/src/features/files/WorkspaceFileImagePreview.tsx @@ -1,24 +1,25 @@ 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"; function ResolvedWorkspaceFileImagePreview(props: { readonly accessibilityLabel: string; readonly uri: string; }) { const [loadError, setLoadError] = useState(null); - const [fullScreenVisible, setFullScreenVisible] = useState(false); + const [preview, setPreview] = useState(null); + const sourceIdentifier = useId(); const imageSource = useMemo( () => ({ uri: props.uri, cache: "force-cache" as const }), [props.uri], ); - const fullScreenImages = useMemo(() => [imageSource], [imageSource]); return ( @@ -27,18 +28,27 @@ 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, + }) + } > - 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 ? ( @@ -47,14 +57,7 @@ function ResolvedWorkspaceFileImagePreview(props: { ) : null} - setFullScreenVisible(false)} - swipeToCloseEnabled - doubleTapToZoomEnabled - /> + setPreview(null)} /> ); } 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} > - + - + {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 9054cb171b50..4c619df79b5b 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 { MarcodeMark } from "../../components/MarcodeMark"; 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 @@ -250,7 +248,7 @@ function AndroidHomeHeader(props: HomeHeaderProps) { : "line.3.horizontal.decrease.circle" } size={16} - tintColor={iconColor} + tintColorClassName={"accent-icon"} type="monochrome" /> @@ -264,12 +262,22 @@ function AndroidHomeHeader(props: HomeHeaderProps) { onPress={props.onOpenSettings} className="size-11 items-center justify-center rounded-full bg-subtle" > - + - + @@ -302,7 +310,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/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/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/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>( () => 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 f3cc1b9ba67e..4f0c324fdab1 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 index b6466abc2710..e3fa761d65cf 100644 --- a/apps/mobile/src/features/terminal/ThreadTerminalPanel.tsx +++ b/apps/mobile/src/features/terminal/ThreadTerminalPanel.tsx @@ -4,7 +4,6 @@ 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"; @@ -36,7 +35,6 @@ export const ThreadTerminalPanel = memo(function ThreadTerminalPanel( 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, @@ -236,7 +234,12 @@ export const ThreadTerminalPanel = memo(function ThreadTerminalPanel( className="h-8 w-8 items-center justify-center rounded-[8px] bg-subtle" onPress={props.onClose} > - + 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..41489bca952c 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(() => { @@ -622,13 +703,16 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread submittedMessageId={submittedMessageId} contentInsetEndAdjustment={combinedContentInsetEndAdjustment} contentTopInset={0} - contentBottomInset={estimatedOverlayHeight} + contentBottomInset={ + estimatedOverlayHeight + (showWorkingControl ? FLOATING_WORKING_CONTROL_COVERAGE : 0) + } contentMaxWidth={contentMaxWidth} layoutVariant={layoutVariant} usesAutomaticContentInsets={props.usesAutomaticContentInsets} onHeaderMaterialVisibilityChange={props.onHeaderMaterialVisibilityChange} onEndFollowEnabledChange={setEndFollowEnabled} skills={selectedProviderSkills} + onUseArtifactTemplate={handleUseArtifactTemplate} loadEarlier={props.loadEarlier ?? null} /> @@ -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..378558762698 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -1,13 +1,37 @@ 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 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 { memo, useCallback, @@ -17,6 +41,7 @@ import { useMemo, useRef, useState, + useId, type ReactNode, type RefObject, } from "react"; @@ -28,6 +53,7 @@ import { } from "react-native-nitro-markdown"; import { ActivityIndicator, + Alert, Image, Platform, type LayoutChangeEvent, @@ -42,16 +68,24 @@ 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, + FadeOut, + 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,8 @@ import { } from "../../native/SelectableMarkdownText"; import { AppText as Text } from "../../components/AppText"; +import { VideoPreviewModal, type VideoPreviewSource } from "../../components/VideoPreviewModal"; +import { VideoAttachmentTile } from "../../components/VideoAttachmentTile"; import { CopyTextButton } from "../../components/CopyTextButton"; import { parseReviewCommentMessageSegments, @@ -84,9 +120,7 @@ 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"; @@ -105,10 +139,14 @@ import { collapsedWorkLogHeight, 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 } 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"; @@ -134,9 +172,10 @@ function formatMessageTime(input: string): string { // 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 THREAD_FEED_LAYOUT_TRANSITION = LinearTransition.duration(THREAD_DISCLOSURE_TRANSITION_MS); +const THREAD_FEED_DISCLOSURE_ENTER_TRANSITION = FadeIn.duration(140); +const THREAD_FEED_DISCLOSURE_EXIT_TRANSITION = FadeOut.duration(120); +const EMPTY_DISCLOSURE_ENTRY_IDS: ReadonlySet = new Set(); // 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 +208,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 +219,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 +238,212 @@ 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, { + 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 +452,9 @@ function ThreadMarkdownImageView(props: { readonly sourceKey: string; readonly unavailable: boolean; readonly alt: string | null; - readonly onPressImage: (uri: string) => void; + readonly onPressPreview: (source: FilePreviewSource) => void; }) { - const codeBackground = useThemeColor("--color-md-code-bg"); + const sourceIdentifier = useId(); const [availableWidth, setAvailableWidth] = useState(0); const [sourceSize, setSourceSize] = useState<{ width: number; height: number } | null>(null); const [failedUri, setFailedUri] = useState(null); @@ -242,12 +487,9 @@ function ThreadMarkdownImageView(props: { > {props.uri === null || failed ? ( {failed ? ( @@ -257,31 +499,35 @@ function ThreadMarkdownImageView(props: { )} ) : ( - props.onPressImage(props.uri!)} - style={{ alignSelf: "flex-start" }} - > - + + props.onPressPreview({ + kind: "image", + uri: props.uri!, + name: props.alt ?? "Image", + sourceIdentifier, + }) + } + style={{ alignSelf: "flex-start" }} > - setFailedUri(props.uri)} - /> - - + + setFailedUri(props.uri)} + /> + + + )} {props.alt ? ( @@ -324,27 +570,27 @@ 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 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 ( ); } @@ -356,7 +602,7 @@ function ThreadMarkdownImageUnavailable(props: { readonly alt: string | null }) sourceKey="unavailable" unavailable alt={props.alt} - onPressImage={() => undefined} + onPressPreview={() => undefined} /> ); } @@ -439,6 +685,115 @@ const MarkdownExternalLink = memo(function MarkdownExternalLink(props: { ); }); +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 +911,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 +940,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"); @@ -974,7 +1325,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 +1334,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 +1350,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-3 min-h-11 flex-row items-center gap-2 border-b border-adaptive-neutral-200-a80-white-a8 px-2" > {entry.label} @@ -1016,7 +1365,7 @@ function renderFeedEntry( @@ -1029,7 +1378,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 +1390,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 +1400,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 +1441,30 @@ function renderFeedEntry( /> ) : null} {attachments.map((attachment) => { - return ( + return isImageAttachment(attachment) ? ( + ) : isFileAttachment(attachment) ? ( + + ) : ( + ); })} - + {timestampLabel} {message.text.trim().length > 0 ? ( @@ -1119,7 +1483,7 @@ 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; } @@ -1129,47 +1493,48 @@ function renderFeedEntry( className={cn(showAssistantMeta ? "mb-5 px-1" : "mb-2 px-1")} {...(enterAnimated ? { entering: FadeIn.duration(220) } : {})} > - {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 +1551,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 +1641,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 +1654,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 +1829,14 @@ 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 previousPresentedFeedRef = useRef | null>(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, ); @@ -1541,10 +1882,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 +1919,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,6 +1932,22 @@ 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), @@ -1599,6 +1959,12 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { } if (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"); } }, @@ -1614,7 +1980,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { sourceKey={imageSource.uri} unavailable={false} alt={image.alt} - onPressImage={(uri) => setExpandedImage({ uri })} + onPressPreview={(source) => setExpandedFile((current) => current ?? source)} /> ); } @@ -1624,15 +1990,37 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { return ( setExpandedImage({ uri })} + srcFragment={markdownImageSourceFragment(image.href)} + 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, + }); + 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 +2187,37 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { props.latestTurn, ], ); + const disclosureEnteringEntryIds = useMemo(() => { + const anchorKey = disclosureAnchorKeyRef.current; + const previousPresentedFeed = previousPresentedFeedRef.current; + if (!disclosureToggleSettling || anchorKey === null || previousPresentedFeed === null) { + return EMPTY_DISCLOSURE_ENTRY_IDS; + } + + const previousIds = new Set(previousPresentedFeed.map((entry) => entry.id)); + const anchorIndex = presentedFeed.findIndex((entry) => entry.id === anchorKey); + const enteringIds = new Set(); + if (anchorIndex < 0) { + return enteringIds; + } + for (let index = anchorIndex + 1; index < presentedFeed.length; index += 1) { + const entryId = presentedFeed[index]!.id; + if (previousIds.has(entryId)) { + break; + } + enteringIds.add(entryId); + } + return enteringIds; + }, [disclosureToggleSettling, presentedFeed]); + + useLayoutEffect(() => { + previousPresentedFeedRef.current = presentedFeed; + }, [presentedFeed]); - // 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 +2282,52 @@ 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(() => { disclosureAnchorKeyRef.current = null; setDisclosureToggleSettling(false); - foldSettleFrameRef.current = null; - foldSettleSecondFrameRef.current = null; + disclosureSettleFrameRef.current = null; + disclosureSettleSecondFrameRef.current = null; }); }); }, []); + 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 +2403,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 +2434,59 @@ 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], ); 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, + disclosureEnteringEntryIds, expandedWorkRows, terminalAssistantMessageIds, unsettledTurnId, @@ -2046,13 +2498,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 +2556,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. @@ -2156,6 +2611,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { 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 +2684,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 7d663d13d816..dd80ed6b91c8 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); @@ -420,21 +431,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; @@ -446,11 +469,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( @@ -464,9 +487,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], ); @@ -484,6 +507,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { updateComposerDraftSettings(selectedProjectDraftKey, { modelSelection: nextSelection, }); + setStickyComposerModelSelection(nextSelection); }, [selectedModel, selectedProjectDraftKey], ); @@ -499,7 +523,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { [selectedProjectDraftKey], ); const replaceAttachments = useCallback( - (nextAttachments: ReadonlyArray) => { + (nextAttachments: ReadonlyArray) => { if (!selectedProjectDraftKey) { return; } @@ -507,12 +531,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], ); @@ -821,6 +847,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); @@ -918,6 +945,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { } clearComposerDraft(pendingTaskDraftKey(editing.messageId)); releaseEditingQueuedMessage(editing.messageId); + scheduleUnusedComposerAttachmentCleanup(editing.attachments); } setEditingPendingTask(null); }, []); @@ -970,17 +998,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 @@ -1030,7 +1069,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { modelOptions, selectedModel, selectedModelOption, - selectedProviderSkills, + selectedProviderStatus, providerGroups, filteredBranches, reset, @@ -1092,7 +1131,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-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..b57b808d7c02 100644 --- a/apps/mobile/src/features/threads/thread-work-log.tsx +++ b/apps/mobile/src/features/threads/thread-work-log.tsx @@ -1,31 +1,206 @@ 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, 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(); +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 +255,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 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 +281,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 +292,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} @@ -228,28 +388,22 @@ export function ThreadWorkLog(props: { /> ) : null} - - {row.status ? ( - - ) : null} - {fullDetail ? ( - + + {viewedImagePath ? ( + + {props.renderImage({ href: viewedImagePath, alt: null, title: null })} + + ) : null} - + ) : null} ); @@ -278,54 +432,89 @@ 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..b91c7152a163 100644 --- a/apps/mobile/src/features/threads/threadPresentation.ts +++ b/apps/mobile/src/features/threads/threadPresentation.ts @@ -53,8 +53,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 +65,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 +77,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 +89,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 +101,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 +117,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..18ae2bbb2345 --- /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/use-composer-path-search", () => ({ + 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..ac703a547fde --- /dev/null +++ b/apps/mobile/src/features/threads/use-composer-command-menu.ts @@ -0,0 +1,298 @@ +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, +} from "@t3tools/client-runtime/providerSkills"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import type { ComposerEditorSelection } from "../../components/ComposerEditor"; +import { useComposerPathSearch } from "../../state/use-composer-path-search"; +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), + ); + + const providerCommands: ComposerCommandItem[] = []; + for (const command of selectedProviderStatus?.slashCommands ?? []) { + 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((skill) => skill.enabled), + ); + 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-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 1961215e4d2c..f0d6476a3ec6 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: "Marcode 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 e0b3f27691d9..aa410eba22c3 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: "Marcode 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/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..49a8b46648e1 100644 --- a/apps/mobile/src/lib/markdownLinks.test.ts +++ b/apps/mobile/src/lib/markdownLinks.test.ts @@ -50,6 +50,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/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/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 db82ead0efca..fb82fddf4903 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/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 8daefd74c90b..7faadff1e19b 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/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/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 f455cc4b226c..3bcb1ced219e 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/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/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/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-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-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 74a0b00e93c7..e861a2ddcf6a 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/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..c43486623c4b 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.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 { @@ -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/package.json b/apps/server/package.json index 74e78325d644..7f93ff4de796 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -1,6 +1,6 @@ { "name": "t3", - "version": "0.0.33", + "version": "0.0.37", "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/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts index aa47a78238bb..8cf9c642384c 100644 --- a/apps/server/src/assets/AssetAccess.test.ts +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -208,6 +208,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..d064ec07529a 100644 --- a/apps/server/src/assets/AssetAccess.ts +++ b/apps/server/src/assets/AssetAccess.ts @@ -37,7 +37,7 @@ 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"; @@ -48,6 +48,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, @@ -79,6 +80,13 @@ const AssetClaimsSchema = Schema.Union([ 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 +109,13 @@ 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; +}; function decodeClaims(encodedPayload: string): AssetClaims | null { try { @@ -286,13 +300,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 +489,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; } 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/attachmentStore.test.ts b/apps/server/src/attachmentStore.test.ts index 79244493aee8..3538b90bb364 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(), "marcode-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 f306ce8f5216..9772b24e69d0 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 b5dc4b92147a..74523c69496b 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), ), ), @@ -237,7 +262,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 +433,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 503b45f16cc8..62c42652dd78 100644 --- a/apps/server/src/bin.ts +++ b/apps/server/src/bin.ts @@ -17,6 +17,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); @@ -57,6 +58,7 @@ export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) => 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/connect.ts b/apps/server/src/cli/connect.ts index d299c49c7c9b..36937302ddbb 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/theme.test.ts b/apps/server/src/cli/theme.test.ts new file mode 100644 index 000000000000..8bf7ea2320a8 --- /dev/null +++ b/apps/server/src/cli/theme.test.ts @@ -0,0 +1,500 @@ +// @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 MARCODE_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: { MARCODE_HOME: baseDir } })), + ), + ); + assert.equal(readSettings(baseDir).defaultTheme, "ocean"); + }), + ); + + // Upstream reads T3CODE_HOME here. If a sync reinstates it, this command + // would follow upstream's variable instead of the user's Marcode home, so + // fail loudly instead. Both variables are set so the assertion never falls + // through to the real default install. + it.effect("prefers MARCODE_HOME over upstream's T3CODE_HOME", () => + Effect.gen(function* () { + const marcodeHome = makeBaseDir(); + const upstreamHome = makeBaseDir(); + yield* runCli(["theme", "set", "ocean"]).pipe( + Effect.provide( + ConfigProvider.layer( + ConfigProvider.fromEnv({ + env: { MARCODE_HOME: marcodeHome, T3CODE_HOME: upstreamHome }, + }), + ), + ), + ); + assert.equal(readSettings(marcodeHome).defaultTheme, "ocean"); + assert.equal(NodeFS.existsSync(settingsPathFor(upstreamHome)), false); + }), + ); + + // 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..25425bb1e08a --- /dev/null +++ b/apps/server/src/cli/theme.ts @@ -0,0 +1,591 @@ +// @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 Marcode, 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."; + } +} + +// ── Marcode fork seam ── upstream reads T3CODE_HOME here. Marcode's other CLI +// commands (`cli/config.ts`, `cli/pair.ts`, `cli/triage.ts`) read MARCODE_HOME, +// so reading upstream's name would make this the one command that ignores the +// user's exported home and silently targets the default install. +const envMarcodeHome = Config.string("MARCODE_HOME").pipe(Config.option); + +const resolveThemePaths = Effect.fn(function* (explicitBaseDir: Option.Option) { + // Same precedence as the rest of the CLI: --base-dir, then MARCODE_HOME, + // then the default home. A provisioning script exporting MARCODE_HOME must + // not have this one command silently target the default install. + const envHome = Option.filter(yield* envMarcodeHome, (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/cloud/bootService.test.ts b/apps/server/src/cloud/bootService.test.ts index bc6c0ac50ae1..22f7905e8b2e 100644 --- a/apps/server/src/cloud/bootService.test.ts +++ b/apps/server/src/cloud/bootService.test.ts @@ -80,9 +80,12 @@ 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"); @@ -93,14 +96,20 @@ it("exports Marcode's base-dir variable to the launch agent", () => { // Upstream's plist exports T3CODE_HOME. `resolveLauncherBaseDir` reads // MARCODE_HOME and throws without it, so an upstream sync that reinstates the // upstream name must fail here instead of shipping a service that never boots. - const plist = BootService.renderBootServicePlist(macPlan, { homeDir: "/Users/theo" }); + const plist = BootService.renderBootServicePlist(macPlan, macRenderOptions); expect(plist).toContain("MARCODE_HOME\n /Users/theo/.t3"); expect(plist).not.toContain("T3CODE_HOME"); }); +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 "); @@ -109,7 +118,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", @@ -122,15 +131,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; @@ -169,27 +180,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) => { @@ -300,6 +317,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", @@ -337,6 +357,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 3ae2a0470b3f..489676e3e6ea 100644 --- a/apps/server/src/cloud/bootService.ts +++ b/apps/server/src/cloud/bootService.ts @@ -101,7 +101,7 @@ export function escapeXmlText(value: string): string { /** 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. @@ -129,6 +129,8 @@ export function renderBootServicePlist( ` `, ` EnvironmentVariables`, ` `, + ` PATH`, + ` ${escapeXmlText(options.environmentPath)}`, // Marcode fork seam: upstream's plist exports T3CODE_HOME. The service // launcher (`resolveLauncherBaseDir`) reads MARCODE_HOME and exits without // it, so the launchd unit must match the systemd unit above. @@ -273,6 +275,7 @@ export function launchdManager(input: { readonly path: Path.Path; readonly homeDir: string; readonly uid: number; + readonly environmentPath: string; }): BootServiceManager { const unitPath = input.path.join( input.homeDir, @@ -292,7 +295,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 @@ -351,6 +358,7 @@ export function selectBootServiceManager(input: { readonly homeDir: string; readonly uid: number | undefined; readonly path: Path.Path; + readonly environmentPath: string; }): BootServiceManager | undefined { if (input.homeDir === "") { return undefined; @@ -359,7 +367,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; } @@ -446,12 +459,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); @@ -672,11 +712,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 c1888e1fce79..3f3a89b06c16 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.workspaceLayoutMutations).toBe(true); expect(second.capabilities.agentActivityPublishing).toBe(false); }), @@ -152,6 +227,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", @@ -171,6 +247,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); @@ -200,7 +277,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 38516f9a9ddf..8d29d2e9a516 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, workspaceLayoutMutations: true, ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }), ...(serverSelfUpdate === "boot-service" ? { serverSelfUpdateProgress: true } : {}), @@ -173,10 +236,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 cdc6ecff73f5..59ce6363111b 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 b416a5b2b062..17dd52dc1bd9 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..7ae036bdc99e 100644 --- a/apps/server/src/http.test.ts +++ b/apps/server/src/http.test.ts @@ -1,7 +1,101 @@ import { expect, it } from "@effect/vitest"; import { describe } 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 { 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("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 +139,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 +160,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 d417459ba7d0..50faeb5e594d 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, @@ -50,20 +51,124 @@ const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]); const DESKTOP_RENDERER_ORIGINS = ["marcode://app", "marcode-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; + 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; + }, + rangeHeader?: string, + ifRangeHeader?: string, +) { + const headers = assetResponseHeaders(asset.path, asset); + if (headers["Content-Type"]?.toLowerCase().startsWith("video/")) { + headers["Accept-Ranges"] = "bytes"; + // If-Range requires a matching validator. A full response is safe when we cannot validate it. + if (rangeHeader && !ifRangeHeader) { + const fs = yield* FileSystem.FileSystem; + const info = 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") { + return yield* HttpServerResponse.file(asset.path, { + status: 206, + offset: range.offset, + bytesToRead: range.bytesToRead, + headers: { ...headers, "Content-Range": range.contentRange }, + }); + } + } + } + return yield* HttpServerResponse.file(asset.path, { status: 200, headers }); +}); + export const httpCompressionLayer = HttpRouter.middleware(HttpMiddleware.compression(), { global: true, }); @@ -117,7 +222,10 @@ export 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 +334,11 @@ 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"], + ).pipe( Effect.orElseSucceed(() => HttpServerResponse.text("Internal Server Error", { status: 500 })), ); }), @@ -265,15 +374,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/keybindings.test.ts b/apps/server/src/keybindings.test.ts index 16f88f65f52c..5297b912de75 100644 --- a/apps/server/src/keybindings.test.ts +++ b/apps/server/src/keybindings.test.ts @@ -195,6 +195,8 @@ 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.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/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 72447a9a1729..b23f042d5554 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -12,6 +12,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"; @@ -19,10 +20,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 { @@ -48,27 +51,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 { @@ -115,6 +121,7 @@ describe("OrchestrationEngine", () => { detail: "historical replay should not be used during bootstrap", }), ), + hasEventAfter: () => Effect.succeed(false), }; const projectionSnapshot = { @@ -221,6 +228,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), @@ -246,6 +254,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(); @@ -816,6 +1023,7 @@ describe("OrchestrationEngine", () => { readAll() { return Stream.fromIterable(events); }, + hasEventAfter: () => Effect.succeed(false), }; const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { @@ -1052,6 +1260,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 b1f7ce713d6e..db043d420522 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; @@ -87,6 +88,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); @@ -170,13 +172,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, @@ -308,7 +334,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 15dfb929675e..25027d125c96 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -138,6 +138,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 { @@ -346,14 +369,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; @@ -637,7 +660,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 }), ]); @@ -683,12 +706,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, @@ -746,6 +771,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti ...existingRow.value, settledOverride: "settled", settledAt: event.payload.settledAt, + unsettledAt: null, updatedAt: event.payload.updatedAt, }); return; @@ -762,6 +788,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; @@ -871,6 +904,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; @@ -907,7 +943,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, }); @@ -937,7 +984,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; } @@ -1021,22 +1070,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 @@ -1051,7 +1122,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, }); @@ -1100,6 +1171,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, @@ -1151,6 +1228,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, @@ -1202,6 +1285,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; } @@ -1221,6 +1310,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, @@ -1558,6 +1653,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) ?? @@ -1761,6 +1862,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 9a0414093fae..a9d88cc88612 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, @@ -306,6 +309,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", @@ -323,6 +332,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { archivedAt: null, settledOverride: null, settledAt: null, + unsettledAt: null, snoozedUntil: null, snoozedAt: null, pinnedAt: "2026-02-24T00:00:01.000Z", @@ -427,6 +437,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", @@ -444,6 +460,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { archivedAt: null, settledOverride: null, settledAt: null, + unsettledAt: null, snoozedUntil: null, snoozedAt: null, pinnedAt: "2026-02-24T00:00:01.000Z", @@ -472,6 +489,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", + }, + ]); + } }), ); @@ -2306,9 +2394,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 @@ -2386,12 +2549,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, { @@ -2399,12 +2564,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 b9157f296295..55313668de54 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -25,6 +25,7 @@ import { type OrchestrationThreadShell, ModelSelection, ProjectId, + ThreadLinkedPullRequest, ThreadId, } from "@t3tools/contracts"; import * as Arr from "effect/Array"; @@ -57,6 +58,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 { @@ -64,6 +66,7 @@ import { type ProjectionFullThreadDiffContext, type ProjectionSnapshotCounts, type ProjectionThreadCheckpointContext, + type ProjectionThreadDetailQuery, type ProjectionSnapshotQueryShape, } from "../Services/ProjectionSnapshotQuery.ts"; @@ -74,6 +77,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)), @@ -91,6 +97,7 @@ const ProjectionThreadProposedPlanDbRowSchema = ProjectionThreadProposedPlan; const ProjectionThreadDbRowSchema = ProjectionThread.mapFields( Struct.assign({ modelSelection: Schema.fromJsonString(ModelSelection), + linkedPullRequest: Schema.NullOr(Schema.fromJsonString(ThreadLinkedPullRequest)), }), ); const ProjectionThreadActivityDbRowSchema = ProjectionThreadActivity.mapFields( @@ -99,6 +106,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({ @@ -141,6 +151,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 @@ -346,6 +363,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) @@ -428,12 +460,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", @@ -464,12 +498,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", @@ -502,12 +538,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", @@ -948,12 +986,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", @@ -1055,6 +1095,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, @@ -1263,15 +1383,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} @@ -1335,6 +1448,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", @@ -1352,6 +1476,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, @@ -1419,6 +1554,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, @@ -1706,12 +1883,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, @@ -1919,12 +2100,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, @@ -2055,12 +2240,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, @@ -2200,12 +2389,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, @@ -2481,12 +2674,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, @@ -2514,14 +2711,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, @@ -2553,25 +2872,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( @@ -2602,17 +2903,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, @@ -2622,12 +2912,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, @@ -2650,21 +2944,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, @@ -2686,8 +2966,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 @@ -2711,7 +2997,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(); } @@ -2764,7 +3052,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 08a0b3e0bd70..42a76ac456aa 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 fb718aeeab84..3bfc6bd40819 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..e5de0c661d0d --- /dev/null +++ b/apps/server/src/orchestration/ThreadSettlementReactor.test.ts @@ -0,0 +1,646 @@ +import { + DEFAULT_SERVER_SETTINGS, + EMPTY_PROJECT_WORKSPACE_LAYOUT, + INITIAL_PROJECT_WORKSPACE_LAYOUT_VERSION, + 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: [], + // Marcode-only fields on OrchestrationProjectShell, added by + // 033_ProjectWorkspaceLayout. Upstream fixtures do not set them. + workspaceLayoutVersion: INITIAL_PROJECT_WORKSPACE_LAYOUT_VERSION, + workspaceLayout: EMPTY_PROJECT_WORKSPACE_LAYOUT, + 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 71ec29cbcf05..c977499de63e 100644 --- a/apps/server/src/orchestration/commandInvariants.test.ts +++ b/apps/server/src/orchestration/commandInvariants.test.ts @@ -216,6 +216,36 @@ 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(); + }); }); describe("normalizeWorkspaceRelativePath", () => { diff --git a/apps/server/src/orchestration/commandInvariants.ts b/apps/server/src/orchestration/commandInvariants.ts index 64d57400b2cc..7eaf9adb5e07 100644 --- a/apps/server/src/orchestration/commandInvariants.ts +++ b/apps/server/src/orchestration/commandInvariants.ts @@ -165,7 +165,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( 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 20d1880b6592..87b341da7529 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -5,6 +5,7 @@ import { type OrchestrationCommand, type OrchestrationEvent, type OrchestrationReadModel, + type OrchestrationThread, type ProjectWorkspaceLayoutOperation, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; @@ -12,7 +13,11 @@ 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 { findWorkspaceLayoutDuplicatePath, findWorkspaceLayoutEntryById, @@ -37,14 +42,10 @@ import { workspaceLayoutInvariantError, } 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 @@ -102,59 +103,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, ); } @@ -202,7 +172,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; @@ -420,7 +390,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) { @@ -684,43 +654,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 / @@ -844,7 +807,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, @@ -1081,6 +1044,9 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" : {}), ...(branch !== undefined ? { branch } : {}), ...(command.worktreePath !== undefined ? { worktreePath: command.worktreePath } : {}), + ...(command.linkedPullRequest !== undefined + ? { linkedPullRequest: command.linkedPullRequest } + : {}), updatedAt: occurredAt, }, }; @@ -1383,7 +1349,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 bedde0dbb0e2..b50de067c7cc 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -353,6 +353,7 @@ export function projectEvent( archivedAt: null, settledOverride: null, settledAt: null, + unsettledAt: null, snoozedUntil: null, snoozedAt: null, deletedAt: null, @@ -433,6 +434,7 @@ export function projectEvent( threads: updateThread(nextBase.threads, payload.threadId, { settledOverride: "settled", settledAt: payload.settledAt, + unsettledAt: null, updatedAt: payload.updatedAt, }), })), @@ -440,14 +442,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": @@ -525,6 +537,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/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 eb0ab544b811..32136b240414 100644 --- a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts +++ b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts @@ -192,6 +192,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { archivedAt: null, settledOverride: null, settledAt: null, + unsettledAt: null, snoozedUntil: null, snoozedAt: null, pinnedAt: null, @@ -255,6 +256,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", @@ -284,6 +286,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { ...row, settledOverride: "active", settledAt: null, + unsettledAt: "2026-03-26T00:00:00.000Z", snoozedUntil: null, snoozedAt: null, pinnedAt: null, @@ -294,9 +297,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/Migrations.test.ts b/apps/server/src/persistence/Migrations.test.ts index e552454b47c8..67e8dda7f81a 100644 --- a/apps/server/src/persistence/Migrations.test.ts +++ b/apps/server/src/persistence/Migrations.test.ts @@ -6,10 +6,17 @@ it("keeps Marcode migration 33 and appends upstream thread lifecycle migrations" // Marcode owns id 33. Upstream migrations that would have claimed it are // shifted up on each sync, so an already-applied install never renumbers. // This pin is intentional: a sync that adds a migration fails here loudly. - // Upstream shipped these as 036-041; Marcode's ProjectWorkspaceLayout holds + // Upstream shipped these as 032-043; Marcode's ProjectWorkspaceLayout holds // 033, so every shared migration sits one id higher here. + // + // Anchored at 33 rather than a trailing slice: a fixed window slides off the + // Marcode-owned id once upstream adds enough migrations, which would quietly + // stop pinning the thing this test exists to pin. + const marcodeOwnedIndex = migrationEntries.findIndex(([id]) => id === 33); + assert.notEqual(marcodeOwnedIndex, -1, "Marcode's migration 33 must stay registered"); + assert.deepStrictEqual( - migrationEntries.slice(-10).map(([id, name]) => [id, name]), + migrationEntries.slice(marcodeOwnedIndex).map(([id, name]) => [id, name]), [ [33, "ProjectWorkspaceLayout"], [34, "ProjectionThreadsSettled"], @@ -22,6 +29,9 @@ it("keeps Marcode migration 33 and appends upstream thread lifecycle migrations" [41, "ProjectionProjectFaviconPath"], // Upstream's 041; renumbered on the way in so 041 stays Marcode's. [42, "AuthSessionClientConnection"], + // Upstream's 042 and 043, renumbered on the b883fc06 sync. + [43, "ProjectionThreadLinkedPullRequest"], + [44, "ProjectionThreadsUnsettledAt"], ], ); diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 231e2721b969..5f797b52a625 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -49,7 +49,7 @@ import Migration0033 from "./Migrations/033_ProjectWorkspaceLayout.ts"; import Migration0034 from "./Migrations/034_ProjectionThreadsSettled.ts"; import Migration0035 from "./Migrations/035_ProjectionThreadsSnoozed.ts"; import Migration0036 from "./Migrations/036_ProjectionThreadTitleRegeneration.ts"; -// Upstream shipped these as 036-041. Marcode's ProjectWorkspaceLayout already +// Upstream shipped these as 036-043. Marcode's ProjectWorkspaceLayout already // occupies 033, so every shared migration sits one id higher here; renumbering // an applied id would re-run or skip it on existing installs. A new upstream // migration is renamed to the next free Marcode id on the way in. @@ -59,6 +59,8 @@ import Migration0039 from "./Migrations/039_ProjectionThreadsPinOrderKey.ts"; import Migration0040 from "./Migrations/040_ProjectionProjectsDefaultThreadEnvMode.ts"; import Migration0041 from "./Migrations/041_ProjectionProjectFaviconPath.ts"; import Migration0042 from "./Migrations/042_AuthSessionClientConnection.ts"; +import Migration0043 from "./Migrations/043_ProjectionThreadLinkedPullRequest.ts"; +import Migration0044 from "./Migrations/044_ProjectionThreadsUnsettledAt.ts"; /** * Migration loader with all migrations defined inline. @@ -113,6 +115,8 @@ export const migrationEntries = [ [40, "ProjectionProjectsDefaultThreadEnvMode", Migration0040], [41, "ProjectionProjectFaviconPath", Migration0041], [42, "AuthSessionClientConnection", Migration0042], + [43, "ProjectionThreadLinkedPullRequest", Migration0043], + [44, "ProjectionThreadsUnsettledAt", Migration0044], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/043_ProjectionThreadLinkedPullRequest.test.ts b/apps/server/src/persistence/Migrations/043_ProjectionThreadLinkedPullRequest.test.ts new file mode 100644 index 000000000000..5e51627e4498 --- /dev/null +++ b/apps/server/src/persistence/Migrations/043_ProjectionThreadLinkedPullRequest.test.ts @@ -0,0 +1,27 @@ +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("043_ProjectionThreadLinkedPullRequest", (it) => { + it.effect("adds the linked pull request column", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + // Upstream ships this as migration 042. Marcode's 033_ProjectWorkspaceLayout + // shifts every shared migration one id higher, so these bounds are +1. + yield* runMigrations({ toMigrationInclusive: 42 }); + yield* runMigrations({ toMigrationInclusive: 43 }); + + 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/043_ProjectionThreadLinkedPullRequest.ts b/apps/server/src/persistence/Migrations/043_ProjectionThreadLinkedPullRequest.ts new file mode 100644 index 000000000000..a026f39c392a --- /dev/null +++ b/apps/server/src/persistence/Migrations/043_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/044_ProjectionThreadsUnsettledAt.ts b/apps/server/src/persistence/Migrations/044_ProjectionThreadsUnsettledAt.ts new file mode 100644 index 000000000000..981d3c78f3a6 --- /dev/null +++ b/apps/server/src/persistence/Migrations/044_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..5bdb47a7f897 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 marcode/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/RepositoryIdentityResolver.test.ts b/apps/server/src/project/RepositoryIdentityResolver.test.ts index c3e16ea90b40..bc4d548cad51 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/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index e099d52e5189..0409b7c691b1 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -36,6 +36,7 @@ import { } from "../Layers/ClaudeProvider.ts"; import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; +import * as ModelManifest from "../ModelManifest.ts"; import { defaultProviderContinuationIdentity, type ProviderDriver, @@ -87,6 +88,7 @@ export type ClaudeDriverEnv = | Crypto.Crypto | FileSystem.FileSystem | HttpClient.HttpClient + | ModelManifest.ModelManifest | Path.Path | ProviderEventLoggers | ServerConfig @@ -125,6 +127,7 @@ export const ClaudeDriver: ProviderDriver = { const httpClient = yield* HttpClient.HttpClient; const serverSettings = yield* ServerSettingsService; const eventLoggers = yield* ProviderEventLoggers; + const modelManifest = yield* ModelManifest.ModelManifest; const processEnv = mergeProviderInstanceEnvironment(environment); const fallbackContinuationIdentity = defaultProviderContinuationIdentity({ driverKind: DRIVER_KIND, @@ -163,13 +166,24 @@ export const ClaudeDriver: ProviderDriver = { }); const capabilitiesCacheKey = yield* makeClaudeCapabilitiesCacheKey(effectiveConfig, cwd); - const checkProvider = checkClaudeProviderStatus( - effectiveConfig, - () => Cache.get(capabilitiesProbeCache, capabilitiesCacheKey), - processEnv, - cwd, - ).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( + checkClaudeProviderStatus( + effectiveConfig, + () => Cache.get(capabilitiesProbeCache, capabilitiesCacheKey), + processEnv, + cwd, + ), + modelManifest.current, + (draft, manifest) => + stampIdentity(ModelManifest.applyModelManifest(draft, manifest, DRIVER_KIND)), + { concurrent: true }, + ), + ), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(Path.Path, path), @@ -182,7 +196,12 @@ export const ClaudeDriver: ProviderDriver = { streamSettings: snapshotSettings.streamSettings, haveSettingsChanged: haveProviderSnapshotSettingsChanged, initialSnapshot: (settings) => - makePendingClaudeProvider(settings.provider).pipe(Effect.map(stampIdentity)), + Effect.zipWith( + makePendingClaudeProvider(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/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index 15d7a1ff0216..80a848c7ce3b 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -39,6 +39,7 @@ 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 { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; @@ -78,6 +79,7 @@ export type CodexDriverEnv = | Crypto.Crypto | FileSystem.FileSystem | HttpClient.HttpClient + | ModelManifest.ModelManifest | Path.Path | ProviderEventLoggers | ServerConfig @@ -119,6 +121,7 @@ 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); @@ -166,8 +169,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 +191,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/GrokDriver.ts b/apps/server/src/provider/Drivers/GrokDriver.ts index 112f11013161..0b4e957fe1b2 100644 --- a/apps/server/src/provider/Drivers/GrokDriver.ts +++ b/apps/server/src/provider/Drivers/GrokDriver.ts @@ -88,6 +88,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({ @@ -113,7 +114,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..58ccf5912025 100644 --- a/apps/server/src/provider/Drivers/OpenCodeDriver.ts +++ b/apps/server/src/provider/Drivers/OpenCodeDriver.ts @@ -34,6 +34,7 @@ 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, @@ -141,13 +142,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 +171,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/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index a45eae6faf3e..2f0efeac5f53 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -413,6 +413,25 @@ describe("ClaudeAdapterLive", () => { ); }); + 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"), + runtimeMode: "full-access", + }); + + 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("forwards claude effort levels into query options", () => { const harness = makeHarness(); return Effect.gen(function* () { @@ -730,6 +749,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"), + "claude-sonnet-4-6", + [{ 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 +808,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])); @@ -1739,92 +1791,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: { + "claude-opus-4-6": { + 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: { + "claude-opus-4-6": { + 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), ); }); @@ -4400,6 +4569,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 +4914,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..6989378d8287 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"; @@ -141,6 +143,8 @@ interface ClaudeTurnState { readonly assistantTextBlocks: Map; readonly assistantTextBlockOrder: Array; readonly capturedProposedPlanKeys: Set; + latestAssistantUsage: unknown | undefined; + compactedSinceLatestAssistantUsage: boolean; nextSyntheticAssistantBlockIndex: number; } @@ -317,7 +321,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; } @@ -543,6 +546,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 +574,9 @@ function makeClaudeTokenUsageSnapshot(input: { ...(input.compactsAutomatically !== undefined ? { compactsAutomatically: input.compactsAutomatically } : {}), + ...(input.autoCompactThreshold !== undefined + ? { autoCompactThreshold: input.autoCompactThreshold } + : {}), }; } @@ -600,18 +607,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, @@ -1296,6 +1291,8 @@ const buildUserMessageEffect = Effect.fn("buildUserMessageEffect")(function* ( } 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; } @@ -2121,29 +2118,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 +2218,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 +2240,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 +2916,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 +2978,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 +3152,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( @@ -3953,6 +3947,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 +4001,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 +4176,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,6 +4236,10 @@ 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; @@ -4196,6 +4275,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 +4310,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 } : {}), @@ -4474,6 +4558,8 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( assistantTextBlocks: new Map(), assistantTextBlockOrder: [], capturedProposedPlanKeys: new Set(), + latestAssistantUsage: undefined, + compactedSinceLatestAssistantUsage: false, nextSyntheticAssistantBlockIndex: -1, }; 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 a5f9da92d2ba..2fa2728da41c 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -56,12 +56,6 @@ 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", @@ -327,9 +321,9 @@ const CLAUDE_MODEL_CATALOG: ReadonlyArray = [ }, ]; -const BUILT_IN_MODELS: ReadonlyArray = CLAUDE_MODEL_CATALOG.map((model) => - isLegacyClaudeModel(model.slug) ? { ...model, isLegacy: true } : model, -); +// Legacy classification happens at the driver boundary via `applyModelManifest`, +// so the catalog itself carries no `isLegacy` flags. +const BUILT_IN_MODELS: ReadonlyArray = CLAUDE_MODEL_CATALOG; function supportsClaudeOpus5(version: string | null | undefined): boolean { return version ? compareSemverVersions(version, MINIMUM_CLAUDE_OPUS_5_VERSION) >= 0 : false; @@ -620,6 +614,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: () => {}, @@ -927,7 +927,13 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( ? 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) { diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index aca50f2c6090..e0e11c6a9676 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 910e2dce2b90..09d8f51e9473 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 d8ebdb7bca5b..776c9c19f95b 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 1df14c1290fd..11ff191cdf2c 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 45794d6b076e..13c396db5c94 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 057b7b09130f..c5cf712096bc 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/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index af395ad39a83..b2da970fe512 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( @@ -2154,6 +2381,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 +2428,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..310550c08f98 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,6 +136,16 @@ 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 = ( diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index bd89dc4f8812..cc25bf7b22a3 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"; @@ -1145,6 +1146,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 +1533,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( diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 7c4fc27cfd22..d0390d40b9d4 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; diff --git a/apps/server/src/provider/ModelManifest.test.ts b/apps/server/src/provider/ModelManifest.test.ts new file mode 100644 index 000000000000..fdcfa9335424 --- /dev/null +++ b/apps/server/src/provider/ModelManifest.test.ts @@ -0,0 +1,185 @@ +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 { HttpClient, HttpClientResponse } from "effect/unstable/http"; + +import * as ServerConfig from "../config.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import { + BUNDLED_MODEL_MANIFEST, + classifyModels, + isLegacyModel, + make, + type ModelManifestData, +} from "./ModelManifest.ts"; + +const CODEX = ProviderDriverKind.make("codex"); +const CLAUDE = ProviderDriverKind.make("claudeAgent"); +const CURSOR = ProviderDriverKind.make("cursor"); + +describe("isLegacyModel (bundled manifest)", () => { + 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, isLegacyModel(BUNDLED_MODEL_MANIFEST, CODEX, 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], + ], + ); + }); + + 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, + isLegacyModel(BUNDLED_MODEL_MANIFEST, CLAUDE, model), + ]), + [ + ["claude-fable-5", false], + ["claude-opus-5", false], + ["claude-sonnet-5", false], + ["claude-opus-4-8", true], + ], + ); + }); + + it("leaves driver kinds without a manifest entry unflagged", () => { + assert.isFalse(isLegacyModel(BUNDLED_MODEL_MANIFEST, CURSOR, "composer-1.5")); + }); +}); + +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 models = [ + model({ slug: "gpt-5.6-sol" }), + // Stale flag from a previous classification pass must be cleared. + model({ slug: "gpt-5.6-luna", isLegacy: true }), + model({ slug: "gpt-5.4" }), + // Custom models are user-defined and never reclassified. + model({ slug: "my-own-model", isCustom: true }), + ]; + assert.deepStrictEqual( + classifyModels(models, BUNDLED_MODEL_MANIFEST, CODEX).map((entry) => [ + entry.slug, + entry.isLegacy ?? false, + ]), + [ + ["gpt-5.6-sol", false], + ["gpt-5.6-luna", false], + ["gpt-5.4", true], + ["my-own-model", false], + ], + ); + }); +}); + +const REMOTE_MANIFEST: ModelManifestData = { + version: 1, + currentModels: { + codex: ["gpt-5.4"], + claudeAgent: ["claude-fable-5"], + }, +}; + +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); + assert.isTrue(isLegacyModel(refreshed, CODEX, "gpt-5.6-sol")); + assert.isFalse(isLegacyModel(refreshed, CODEX, "gpt-5.4")); + + // 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.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..cb9494992287 --- /dev/null +++ b/apps/server/src/provider/ModelManifest.ts @@ -0,0 +1,221 @@ +/** + * ModelManifest — decides which provider models are current and which belong + * in the model picker's legacy section. + * + * The classification data (current slugs per driver kind) lives in + * `model-manifest.json` next to this file. The bundled copy ships with every + * release. At runtime the service refreshes it from the same file on `main` + * via raw.githubusercontent.com, so a new model can leave the legacy section + * with a commit to `main` instead of a release. Preference order is remote, + * then the on-disk copy of the last successful fetch, then the bundle. A + * failed fetch never fails a provider check. + * + * Drivers apply the manifest to snapshot drafts with `applyModelManifest` + * before publishing, so every path that produces models (pending, probe, + * error fallbacks) is classified the same way. + */ +import type { ProviderDriverKind, 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 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; + +/** + * `version` gates breaking schema changes: a build only accepts remote + * manifests whose version it understands, and keeps its bundled copy + * otherwise. `currentModels` is keyed by driver kind; kinds absent from the + * map have no legacy concept and their models are left unflagged. + */ +const ModelManifestSchema = Schema.Struct({ + version: Schema.Literal(1), + currentModels: Schema.Record(Schema.String, Schema.Array(Schema.String)), +}); +export type ModelManifestData = typeof ModelManifestSchema.Type; + +const decodeManifest = Schema.decodeUnknownEffect(ModelManifestSchema); + +export const BUNDLED_MODEL_MANIFEST: ModelManifestData = + Schema.decodeUnknownSync(ModelManifestSchema)(bundledManifestJson); + +/** 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 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/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..8fd7af3c5610 100644 --- a/apps/server/src/provider/acp/AcpRuntimeModel.ts +++ b/apps/server/src/provider/acp/AcpRuntimeModel.ts @@ -264,25 +264,166 @@ 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 { @@ -326,7 +467,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 +485,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 +566,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 +727,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 +797,7 @@ export function parseSessionUpdateEvent(params: EffectAcpSchema.SessionNotificat events.push({ _tag: "ToolCallUpdated", toolCall, - rawPayload: params, + rawPayload: boundToolCallRawPayload(params, upd, toolCall), }); } break; @@ -559,7 +808,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 e1438888bec0..0aebda516fca 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 Marcode 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 6cf0a66ee799..32d90646b0bd 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..7022ce226170 --- /dev/null +++ b/apps/server/src/provider/model-manifest.json @@ -0,0 +1,13 @@ +{ + "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", "claude-opus-5", "claude-sonnet-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/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 188f82634627..2e06abbea932 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -11,6 +11,7 @@ import { CommandId, DEFAULT_SERVER_SETTINGS, EMPTY_PROJECT_WORKSPACE_LAYOUT, + type DpopFailureReason, EnvironmentId, EventId, GitCommandError, @@ -20,6 +21,7 @@ import { ExternalLauncherCommandNotFoundError, OrchestrationThreadDetailSnapshot, type OrchestrationThreadStreamItem, + type OrchestrationThreadActivity, type OrchestrationThreadShell, TerminalNotRunningError, type OrchestrationCommand, @@ -31,6 +33,7 @@ import { ProviderInstanceId, ResolvedKeybindingRule, ThreadId, + TurnId, WS_METHODS, WsRpcGroup, EditorId, @@ -58,6 +61,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"; @@ -103,16 +107,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"; @@ -195,6 +208,44 @@ const PROJECT_WORKSPACE_LAYOUT_DEFAULTS = { workspaceLayoutVersion: INITIAL_PROJECT_WORKSPACE_LAYOUT_VERSION, workspaceLayout: EMPTY_PROJECT_WORKSPACE_LAYOUT, } 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", @@ -290,6 +341,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) => @@ -395,6 +451,7 @@ const buildAppUnderTest = (options?: { config?: Partial; layers?: { keybindings?: Partial; + environmentTheme?: Partial; providerRegistry?: Partial; providerService?: Partial; serverSettings?: Partial; @@ -413,6 +470,7 @@ const buildAppUnderTest = (options?: { >; terminalManager?: Partial; orchestrationEngine?: Partial; + threadDeletionReactor?: Partial; analyticsService?: Partial; projectionSnapshotQuery?: Partial; checkpointDiffQuery?: Partial; @@ -626,17 +684,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( @@ -672,6 +738,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)({ @@ -788,13 +855,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)({ @@ -1117,6 +1191,7 @@ const exchangeAccessToken = ( readonly _tag?: string; readonly code?: string; readonly reason?: string; + readonly dpopFailureReason?: DpopFailureReason; readonly traceId?: string; }>(response); return { @@ -1508,6 +1583,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 = { @@ -1619,6 +1729,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(); @@ -1799,6 +1951,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(); @@ -1848,6 +2032,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)), ); @@ -1923,6 +2108,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)), ); @@ -4038,10 +4224,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(); @@ -4059,6 +4273,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", () => @@ -4536,6 +4767,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), []); }), ), ); @@ -4653,6 +4991,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"), @@ -4706,7 +5045,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); @@ -4722,6 +5061,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(); @@ -4739,6 +5123,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 = [ @@ -5216,7 +5678,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* () { @@ -5249,22 +5713,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", @@ -6370,6 +6969,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; @@ -7248,7 +8047,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[] = []; @@ -7306,64 +8105,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)), ); @@ -8153,6 +8928,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/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 6de8ee6130ef..211e7ac45624 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/telemetry/AnalyticsService.test.ts b/apps/server/src/telemetry/AnalyticsService.test.ts index 1e8f2d043ac9..02e37acfa693 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 6c316207dd17..9446af50a78d 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/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/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 16c9dee021d7..4169f9119302 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 f80a34e88734..7e68a4002fb0 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/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/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index dfd62dc4fff8..a1eb82f8f218 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -782,18 +782,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("stderr redaction", () => { @@ -2103,6 +2091,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 b66526abb611..54a6d687732a 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -546,6 +546,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, @@ -580,7 +581,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)`, @@ -2087,6 +2088,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/ws.ts b/apps/server/src/ws.ts index ae4ae790efc7..47cdbe94cee8 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, @@ -71,18 +77,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, @@ -140,16 +149,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)}`); } @@ -371,7 +389,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 @@ -393,14 +417,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( @@ -409,6 +475,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: @@ -422,24 +489,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; @@ -983,7 +1050,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, @@ -996,6 +1063,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; } @@ -1083,6 +1155,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"), ), @@ -1105,6 +1185,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, @@ -1114,9 +1202,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( @@ -1134,6 +1220,12 @@ const makeWsRpcLayer = ( }, settings, shellResumeCompletionMarker: true, + ...(fileManagerRevealKind === undefined + ? {} + : { + shellRevealInFileManager: true, + shellRevealInFileManagerKind: fileManagerRevealKind, + }), threadResumeCompletionMarker: true, threadSnapshotPagination: true, }; @@ -1167,23 +1259,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, @@ -1194,7 +1280,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)), ), ) @@ -1203,50 +1289,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( @@ -1446,17 +1521,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 @@ -1505,8 +1578,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; @@ -1547,8 +1622,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; @@ -2436,7 +2513,7 @@ const makeWsRpcLayer = ( ), { "rpc.aggregate": "preview" }, ), - [WS_METHODS.subscribeServerConfig]: (_input) => + [WS_METHODS.subscribeServerConfig]: (input) => observeRpcStreamEffect( WS_METHODS.subscribeServerConfig, Effect.gen(function* () { @@ -2458,6 +2535,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) => ({ @@ -2473,7 +2567,10 @@ const makeWsRpcLayer = ( const liveUpdates = Stream.merge( keybindingsUpdates, - Stream.merge(providerStatuses, settingsUpdates), + Stream.merge( + providerStatuses, + Stream.merge(settingsUpdates, environmentThemeUpdates), + ), ); return Stream.concat( @@ -2573,20 +2670,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 a24d3b769f17..b4bf3c345c4c 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.37", "private": true, "type": "module", "scripts": { @@ -43,6 +43,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", 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 922df90a264f..79a883df6ee2 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/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..74db764005c0 100644 --- a/apps/web/src/browser/HostedBrowserWebview.tsx +++ b/apps/web/src/browser/HostedBrowserWebview.tsx @@ -9,6 +9,7 @@ import { usePreviewBridge } from "~/components/preview/usePreviewBridge"; import { cn } 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,10 @@ export function HostedBrowserWebview(props: { if (!config) return null; + const renderingActive = active || backgroundActivity || pictureInPicture || recordingActive; const wrapperStyle = resolveHostedBrowserWebviewWrapperStyle({ active, + renderingActive, cornerRadius: presentation.cornerRadius, rect: lastRect, hiddenSize, @@ -244,6 +252,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 +268,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 { - events, - frameSubscription, - onFrame: vi.fn((listener: (frame: Frame) => void) => { - frameSubscription.listener = listener; - return () => { - if (frameSubscription.listener === listener) frameSubscription.listener = null; - }; - }), - registrySet: vi.fn((_atom: unknown, value: { readonly tabIds: ReadonlySet }) => { - events.push( - value.tabIds.size === 0 ? "clear" : `publish:${Array.from(value.tabIds).join(",")}`, - ); - }), - save: vi.fn(async (tabId: string) => ({ - id: "recording-test", - tabId, - path: "/tmp/recording-test.webm", - mimeType: "video/webm" as const, - sizeBytes: 0, - createdAt: "2026-06-26T00:00:00.000Z", - })), - 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?.({ +const { clientSettings, events, getUserMedia, registrySet, save, startScreencast, stopScreencast } = + vi.hoisted(() => { + const events: string[] = []; + return { + clientSettings: { browserRecordingFrameRate: 30 as 30 | 60 }, + events, + getUserMedia: vi.fn(), + registrySet: vi.fn((_atom: unknown, value: { readonly tabIds: ReadonlySet }) => { + events.push( + value.tabIds.size === 0 ? "clear" : `publish:${Array.from(value.tabIds).join(",")}`, + ); + }), + save: vi.fn(async (tabId: string) => ({ + id: "recording-test", 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, - }; -}); + path: "/tmp/recording-test.webm", + mimeType: "video/webm" as const, + sizeBytes: 0, + createdAt: "2026-06-26T00:00:00.000Z", + })), + startScreencast: vi.fn(async (tabId: string) => { + events.push("start-screencast"); + return { sourceId: `source:${tabId}`, width: 1000, height: 620 }; + }), + stopScreencast: vi.fn(async () => undefined), + }; + }); vi.mock("~/components/preview/previewBridge", () => ({ previewBridge: { - recording: { onFrame, save, startScreencast, stopScreencast }, + recording: { onFrame: vi.fn(), save, startScreencast, stopScreencast }, }, })); @@ -79,32 +39,49 @@ 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, 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 +93,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 +102,31 @@ 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); - } - } - 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: "" }), - }), + getUserMedia.mockResolvedValue({ + getTracks: () => [{ stop: vi.fn() }], }); + vi.stubGlobal("navigator", { mediaDevices: { getUserMedia } }); + useBrowserSurfaceStore.setState({ activityByTabId: {}, byTabId: {} }); }); afterEach(() => { @@ -179,152 +136,195 @@ 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 }, - }, - }; - - await startBrowserRecording("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); + return { sourceId: `source:${tabId}`, width: 1000, height: 620 }; + }); + getUserMedia.mockImplementationOnce(async () => { + expect(animationFrameCount).toBe(2); + expect(useBrowserSurfaceStore.getState().activityByTabId["background-tab"]).toBe(1); + return { getTracks: () => [{ stop: vi.fn() }] }; + }); - expect(startScreencast).toHaveBeenCalledWith("recording-tab"); - expect(events).toEqual(["start-screencast", "publish:recording-tab"]); + await startBrowserRecording("background-tab"); + expect(useBrowserSurfaceStore.getState().activityByTabId["background-tab"]).toBe(1); - await stopBrowserRecording("recording-tab"); + 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 at the preview's current dimensions", async () => { + const stopTrack = vi.fn(); + const stream = { getTracks: () => [{ stop: stopTrack }] } as unknown as MediaStream; + getUserMedia.mockResolvedValueOnce(stream); + + await startBrowserRecording("recording-tab"); + + expect(getUserMedia).toHaveBeenCalledWith({ + audio: false, + video: { + mandatory: { + chromeMediaSource: "tab", + chromeMediaSourceId: "source:recording-tab", + minWidth: 1000, + maxWidth: 1000, + minHeight: 620, + maxHeight: 620, + maxFrameRate: 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 {}; + it("uses the configured recording frame rate", async () => { + clientSettings.browserRecordingFrameRate = 60; + + await startBrowserRecording("recording-tab"); + + expect(getUserMedia).toHaveBeenCalledWith({ + audio: false, + video: { + mandatory: expect.objectContaining({ maxFrameRate: 60 }), }, - getContext: () => ({ drawImage, fillRect, fillStyle: "" }), - }; - vi.stubGlobal("document", { - createElement: () => canvas, }); - 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(); + getUserMedia.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"); - }); - 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; + expect(FakeMediaRecorder.instances[0]?.options).toEqual({ + mimeType: "video/webm;codecs=av1", + }); + expect(save).toHaveBeenCalledWith( + "recording-tab", + "video/webm;codecs=av01", + expect.any(Uint8Array), + ); + }); - constructor() { - DeferredImage.instances.push(this); - } + it("lets the browser select the format when no preferred encoding is supported", async () => { + FakeMediaRecorder.supportedTypes = new Set(); + FakeMediaRecorder.outputMimeType = "video/platform-default"; - addEventListener(type: string, listener: EventListenerOrEventListenerObject): void { - if (type === "load") this.loadListener = listener; - } + await startBrowserRecording("recording-tab"); + await stopBrowserRecording("recording-tab"); - set src(_value: string) {} + expect(FakeMediaRecorder.instances[0]?.options).toBeUndefined(); + expect(save).toHaveBeenCalledWith( + "recording-tab", + "video/platform-default", + expect.any(Uint8Array), + ); + }); - 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 () => { + getUserMedia.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(); + getUserMedia.mockImplementationOnce( + () => + new Promise((resolve) => { + finishCapture = resolve; + }), + ); + + const startPromise = startBrowserRecording("recording-tab"); + await vi.waitFor(() => expect(getUserMedia).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 +336,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"]), @@ -372,22 +362,6 @@ describe("browser 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,18 +382,12 @@ 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; }); + return { sourceId: "source:recording-tab", width: 1000, height: 620 }; }); const firstStart = startBrowserRecording("recording-tab"); @@ -484,7 +452,7 @@ describe("browser recording", () => { await new Promise((resolve) => { finishStartingScreencast = resolve; }); - emitRecordingFrame(); + return { sourceId: "source:recording-tab", width: 1000, height: 620 }; }); const startPromise = startBrowserRecording("recording-tab"); @@ -508,7 +476,7 @@ describe("browser recording", () => { await new Promise((resolve) => { finishStartingScreencast = resolve; }); - emitRecordingFrame(); + return { sourceId: "source:recording-tab", width: 1000, height: 620 }; }); const firstStart = startBrowserRecording("recording-tab"); @@ -535,7 +503,7 @@ describe("browser recording", () => { await new Promise((resolve) => { finishStartingScreencast = resolve; }); - emitRecordingFrame(); + return { sourceId: "source:recording-tab", width: 1000, height: 620 }; }); stopScreencast.mockRejectedValueOnce(new Error("initial stop failed")); @@ -569,15 +537,14 @@ describe("browser recording", () => { await new Promise((resolve) => { finishStartingScreencast = resolve; }); - emitRecordingFrame(); + return { sourceId: "source:recording-tab", width: 1000, height: 620 }; }); 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 +560,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..e242f25c7d2d 100644 --- a/apps/web/src/browser/browserRecording.ts +++ b/apps/web/src/browser/browserRecording.ts @@ -1,6 +1,6 @@ import type { DesktopPreviewRecordingArtifact, - DesktopPreviewRecordingFrame, + DesktopPreviewRecordingSource, ScopedThreadRef, } from "@t3tools/contracts"; import { useAtomValue } from "@effect/atom-react"; @@ -8,8 +8,10 @@ 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 +36,24 @@ export class BrowserRecordingConflictError extends Schema.TaggedErrorClass()( - "BrowserRecordingCanvasUnavailableError", +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, - width: Schema.Number, - height: Schema.Number, + timeoutMs: Schema.Number, }, ) { override get message(): string { - return `Browser recording canvas ${this.width}x${this.height} is unavailable for tab ${this.tabId}.`; + return `Browser recording media capture for tab ${this.tabId} did not settle within ${this.timeoutMs}ms.`; } } @@ -52,11 +62,10 @@ export class BrowserRecordingOperationError extends Schema.TaggedErrorClass; - 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 +124,15 @@ export function useActiveBrowserRecordingTabIds(): ReadonlySet { } const activeRecordings = new Map(); -let unsubscribeFrames: (() => void) | null = null; + +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 +170,38 @@ 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 = ( + source: DesktopPreviewRecordingSource, + frameRate: number, +): Promise => + navigator.mediaDevices.getUserMedia({ + audio: false, + video: { + mandatory: { + chromeMediaSource: "tab", + chromeMediaSourceId: source.sourceId, + minWidth: source.width, + maxWidth: source.width, + minHeight: source.height, + maxHeight: source.height, + maxFrameRate: frameRate, + }, + } as unknown as MediaTrackConstraints, + }); const stopMediaRecorder = async (recorder: MediaRecorder | null): Promise => { if (!recorder || recorder.state === "inactive") return; @@ -220,17 +212,74 @@ const stopMediaRecorder = async (recorder: MediaRecorder | null): Promise await stopped; }; +const stopMediaStream = (stream: MediaStream | null): void => { + for (const track of stream?.getTracks() ?? []) track.stop(); +}; + +const captureTabMediaStreamWithTimeout = async ( + tabId: string, + source: DesktopPreviewRecordingSource, + frameRate: number, +): Promise => { + let acceptStream = true; + let timeoutId: number | null = null; + const streamPromise = captureTabMediaStream(source, frameRate).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 +296,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 +326,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,61 +376,35 @@ 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 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" }, }; activeRecordings.set(tabId, recording); + publishActiveRecordingTabIds(); try { + const frameRatePromise = ensureClientSettingsHydrated().then( + () => getClientSettings().browserRecordingFrameRate, + ); + const [frameRate] = await Promise.all([frameRatePromise, waitForBrowserRecordingPaint()]); + let source: DesktopPreviewRecordingSource; try { - unsubscribeFrames ??= bridge.recording.onFrame(drawFrame); - } catch (cause) { - clearActiveRecording(recording); - throw new BrowserRecordingOperationError({ - operation: "subscribe-frames", - tabId, - cause, - }); - } - try { - await bridge.recording.startScreencast(tabId); + source = await bridge.recording.startScreencast(tabId); } catch (cause) { if (!isRecordingStarting(recording)) { throw recordingStartupCancelledError(recording, cause); @@ -420,34 +435,29 @@ export async function startBrowserRecording( throw recordingStartupCancelledError(recording); }; await throwIfStartupCancelled(); - const hasFirstFrame = await waitForFirstFrameSize(recording); - await throwIfStartupCancelled(); - if (!hasFirstFrame) { - const cause = new Error(`No valid recording frame arrived for tab ${tabId}.`); + try { + recording.stream = await captureTabMediaStreamWithTimeout(tabId, source, frameRate); + } catch (cause) { const cleanupCause = await cleanupFailedRecordingStart(bridge, recording); + if (isBrowserRecordingCaptureTimeoutError(cause) && cleanupCause === undefined) throw cause; throw new BrowserRecordingOperationError({ - operation: "wait-first-frame", + operation: "capture-media-stream", tabId, cause: cleanupCause === undefined ? cause : new AggregateError( [cause, cleanupCause], - `Browser recording frame wait and cleanup failed for tab ${tabId}.`, + `Browser media capture and cleanup failed for tab ${tabId}.`, { cause }, ), }); } + await throwIfStartupCancelled(); - 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(recording.stream); recording.recorder = recorder; recorder.addEventListener("dataavailable", (event) => { if (event.data.size > 0) chunks.push(event.data); @@ -487,9 +497,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 +525,7 @@ const finalizeBrowserRecording = async ( cause, }); } - if (!recording.recorder || !recording.mimeType) { + if (!recording.recorder) { result = { _tag: "Success", artifact: null }; } else { try { @@ -530,11 +537,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 +571,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 +625,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); 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..c2c78f4ac61c 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,25 @@ describe("resolveHostedBrowserWebviewWrapperStyle", () => { height: 852, zIndex: -1, pointerEvents: "none", + visibility: "hidden", + }); + }); + + it("keeps an active background task paintable offscreen", () => { + const style = resolveHostedBrowserWebviewWrapperStyle({ + active: false, + renderingActive: 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..1e6ff0beabe5 100644 --- a/apps/web/src/browser/hostedBrowserWebviewStyle.ts +++ b/apps/web/src/browser/hostedBrowserWebviewStyle.ts @@ -13,18 +13,19 @@ 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 cornerRadius?: number; readonly rect: BrowserSurfaceRect | null; readonly hiddenSize: HostedBrowserWebviewSize; }): HostedBrowserWebviewWrapperStyle { - const { active, cornerRadius = 0, hiddenSize, rect } = input; + const { active, cornerRadius = 0, hiddenSize, rect, renderingActive } = input; if (active && rect) { return { left: rect.x, @@ -44,9 +45,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: renderingActive ? "visible" : "hidden", }; } diff --git a/apps/web/src/cloud/linkEnvironment.test.ts b/apps/web/src/cloud/linkEnvironment.test.ts index c391671f325a..6f4b37574feb 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 e1cd3379a027..2cc09210252e 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 282c2a37ce33..6d753b214a43 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -3,29 +3,53 @@ 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 * 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 +69,20 @@ 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 { type ExpandedImagePreview } from "./chat/ExpandedImagePreview"; 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 +95,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 { openFileInFloatingEditor } from "../editor/open-floating-file"; import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering"; import { fnv1a32 } from "../lib/diffRendering"; @@ -77,33 +117,44 @@ 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 { cn } from "../lib/utils"; -import { useActiveEnvironmentId } from "../state/entities"; +import { useRemoteOpenResolution, type RemoteOpenMode } from "../remoteOpen"; +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 +166,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 +176,105 @@ 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; +}): boolean { + return input.canOpenInEditor || input.canOpenInBrowser || input.canOpenInPanel; +} + +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 +328,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 +362,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 +377,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 +475,7 @@ function extractPreCodeMeta(node: unknown): string | undefined { type MarkdownAstNode = { type?: string; meta?: unknown; + url?: string; data?: { hProperties?: Record; }; @@ -352,15 +502,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 +661,7 @@ function MarkdownTable({ children, ...props }: React.ComponentProps<"table">) { className="chat-markdown-table-container" data-expanded={expanded ? "true" : "false"} > - + {children}
    @@ -858,14 +1008,19 @@ 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; + 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("\\", "/"); @@ -876,14 +1031,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); } @@ -944,7 +1097,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"; @@ -978,57 +1134,137 @@ 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_IMAGE_BOUNDS_CLASS_NAME = "max-h-[30rem] max-w-[min(100%,30rem)]"; +const CHAT_MARKDOWN_IMAGE_SIZE_CLASS_NAME = cn( + "h-auto w-auto object-contain", + CHAT_MARKDOWN_IMAGE_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})`; +} -// block! outranks the unlayered `.chat-markdown img { display: inline-block }` -// rule, keeping workspace images on the same block layout as their placeholder. +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; +} + +const CHAT_MARKDOWN_WORKSPACE_IMAGE_LAYOUT_CLASS_NAME = "inline-block!"; 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_WORKSPACE_IMAGE_LAYOUT_CLASS_NAME, + "rounded-lg border border-border/40", ); +const MarkdownLinkContext = React.createContext(false); + +function expandableMarkdownImageProps( + onImageExpand: ((preview: ExpandedImagePreview) => void) | undefined, + src: string, + alt: string, +) { + 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 }], 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 }) { +function ChatMarkdownImageFallback(props: { + readonly alt: string; + readonly copyMarkdown?: string | undefined; +}) { return ( - - - {props.alt.length > 0 ? `Image unavailable · ${props.alt}` : "Image unavailable"} + + + + {props.alt.length > 0 ? `Image unavailable · ${props.alt}` : "Image unavailable"} + ); } -/** 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; +/** Environment-hosted images load through a signed asset URL. */ +export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props: { + readonly environmentId: EnvironmentId; + readonly resource: Extract; readonly alt: string; + readonly copyMarkdown?: string; + readonly srcFragment?: string; + readonly style?: CSSProperties | undefined; + readonly onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined; }) { - const assetUrl = useAssetUrlState(props.threadRef.environmentId, { - _tag: "workspace-file", - threadId: props.threadRef.threadId, - path: props.path, - }); + const assetUrl = useAssetUrlState(props.environmentId, props.resource); const [failedUrl, setFailedUrl] = useState(null); if (assetUrl._tag === "Failure" || (assetUrl._tag === "Success" && failedUrl === assetUrl.url)) { - return ; + return ; } if (assetUrl._tag !== "Success") { return ( ); } + const src = assetUrl.url + (props.srcFragment ?? ""); return ( {props.alt} setFailedUrl(assetUrl.url)} /> ); @@ -1212,10 +1448,16 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ threadRef, onOpen, onOpenInPanel, + openInEditorMenuLabel, onOpenInBrowser, + onReveal, + revealLabel, className, }: MarkdownFileLinkProps) { const handleOpenInEditor = useCallback(() => { + if (!onOpen) { + return; + } void (async () => { try { const result = await onOpen(targetPath); @@ -1300,6 +1542,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) { @@ -1339,25 +1619,23 @@ 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" }, + ...(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 === "open") { @@ -1368,6 +1646,10 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ handleOpenInBrowser(); return; } + if (clicked === "reveal") { + handleRevealInFileManager(); + return; + } if (clicked === "copy-relative") { handleCopy(displayPath, "Relative path"); return; @@ -1382,34 +1664,100 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ ); } }, - [displayPath, handleCopy, handleOpenInBrowser, handleOpenInEditor, onOpenInBrowser, targetPath], + [ + displayPath, + handleCopy, + handleOpenInBrowser, + handleOpenInEditor, + handleRevealInFileManager, + onOpenInBrowser, + 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, + }); + 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} + > + + + ) : ( + + ) } /> { + 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(() => { @@ -1483,7 +1879,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); @@ -1512,6 +1908,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 @@ -1526,6 +1923,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) { @@ -1569,6 +2014,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( @@ -1594,24 +2059,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 @@ -1622,7 +2086,9 @@ function ChatMarkdown({ copyMarkdown: string, className?: 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); @@ -1646,8 +2112,15 @@ function ChatMarkdown({ copyMarkdown={copyMarkdown} theme={resolvedTheme} threadRef={threadRef} - onOpen={openInPreferredEditor} + {...(canUseShellActions ? { onOpen: openInPreferredEditor } : {})} onOpenInPanel={openFileInPanel} + openInEditorMenuLabel={preferredEditorMenuLabel} + onReveal={ + canUseShellActions && revealInFileManagerLabel !== undefined + ? () => revealMarkdownFileInFileManager(fileLinkMeta) + : undefined + } + revealLabel={revealInFileManagerLabel} onOpenInBrowser={ threadRef && isPreviewSupportedInRuntime() && @@ -1661,6 +2134,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)}

    ; }, @@ -1740,12 +2222,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) => { @@ -1783,18 +2289,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 )} ); @@ -1837,10 +2360,20 @@ 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 : onImageExpand; + 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); if (imageSource._tag === "Direct") { return ( {altText} ); } if (imageSource._tag === "WorkspaceFile" && threadRef) { return ( - ); } - return ; + return ; }, table({ node: _node, ...props }) { return ; @@ -1899,24 +2446,42 @@ function ChatMarkdown({ }, }; }, [ + canUseShellActions, cwd, diffThemeName, fileLinkParentSuffixByPath, inlineCodeFileLinkMetaByText, + imageBaseDir, isStreaming, markdownFileLinkMetaByHref, onTaskListChange, + onUseArtifactTemplate, + onImageExpand, 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. @@ -1929,9 +2494,7 @@ function ChatMarkdown({ onCopy={handleCopy} > ({ 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", () => ({ 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 +24,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 +59,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: "workspace-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( @@ -96,13 +157,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..6e391ab79e95 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,8 @@ import { dismissBranchMismatchForSession, ENVIRONMENT_RECONNECT_WARNING_GRACE_MS, getStartedThreadModelChangeBlockReason, + loadVideoPreviewUrl, + isVideoPreviewRequestCurrent, hasEnvironmentReconnectWarningGraceElapsed, hasServerAcknowledgedLocalDispatch, isBranchMismatchDismissedForSession, @@ -33,16 +36,57 @@ import { resolveDraftHeroState, scheduleEnvironmentReconnectWarning, startNewThreadForProject, + codexArtifactTemplatePromptToAppend, shouldDockDraftHeroForSubmission, shouldReleaseTimelineAnchorForToolActivity, shouldShowBranchMismatchBanner, + shouldShowPlanFollowUpPrompt, shouldWriteThreadErrorToCurrentServerThread, } from "./ChatView.logic"; +describe("loadVideoPreviewUrl", () => { + it("loads video bytes into an object URL", async () => { + const objectUrl = await loadVideoPreviewUrl("data:video/mp4;base64,AA=="); + expect(objectUrl).toMatch(/^blob:/); + URL.revokeObjectURL(objectUrl); + }); + + it("stops loading when the preview request is cancelled", async () => { + const controller = new AbortController(); + controller.abort(); + + await expect( + loadVideoPreviewUrl("data:video/mp4;base64,AA==", controller.signal), + ).rejects.toMatchObject({ name: "AbortError" }); + }); +}); + +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 +115,7 @@ describe("draft hero submission transition", () => { expect( resolveDraftPromotionNavigationTarget({ serverThreadRef: { environmentId, threadId }, - serverThreadStarted: true, + serverThread: makeThread({ latestTurn: completedTurn }), backgroundSubmissionPending: true, }), ).toBeNull(); @@ -272,6 +316,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 +695,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 61b584866f2c..d838ece02c08 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -4,6 +4,7 @@ import { ProjectId, type MessageId, type ModelSelection, + type ProviderInteractionMode, type ProviderDriverKind, type ServerProvider, type ScopedProjectRef, @@ -11,7 +12,18 @@ import { type ThreadId, type TurnId, } from "@t3tools/contracts"; -import { type ChatMessage, type SessionPhase, type Thread, type ThreadShell } from "../types"; +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 +44,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 +110,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 +299,27 @@ export function revokeBlobPreviewUrl(previewUrl: string | undefined): void { URL.revokeObjectURL(previewUrl); } +export async function loadVideoPreviewUrl(url: string, signal?: AbortSignal): Promise { + const response = await fetch(url, signal ? { signal } : {}); + if (!response.ok) throw new Error(`Could not load video (${response.status}).`); + return URL.createObjectURL(await response.blob()); +} + +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 +332,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 +481,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 88364e07b546..c519efe2fb25 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,7 +48,6 @@ 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 { @@ -82,6 +79,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 +96,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 +115,10 @@ import { type PendingUserInputDraftAnswer, } from "../pendingUserInput"; import { useUiStateStore } from "../uiStateStore"; +import { + latestWorkspaceMutationId, + useWorkspaceMutationRefresh, +} from "../hooks/useWorkspaceMutationRefresh"; import { buildPlanImplementationThreadTitle, buildPlanImplementationPrompt, @@ -125,6 +130,8 @@ import { DEFAULT_THREAD_TERMINAL_ID, MAX_TERMINALS_PER_GROUP, type ChatMessage, + isImageAttachment, + videoMimeType, type SessionPhase, type Thread, type TurnDiffSummary, @@ -184,6 +191,7 @@ import { CheckCircle2Icon, ChevronDownIcon, GitBranchIcon, + Minimize2Icon, PaperclipIcon, WifiOffIcon, } from "lucide-react"; @@ -202,7 +210,11 @@ import { subscribeUnifiedWorkspaceCommandRun } from "~/unifiedWorkspace/activate 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, @@ -210,6 +222,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"; @@ -228,6 +241,8 @@ import { buildDraftThreadRouteParams, buildThreadRouteParams } from "../threadRo import { beginBackgroundDraftSubmissionByRef, clearBackgroundDraftSubmissionByRef, + composerDraftHasUserContent, + type ComposerFileAttachment, type ComposerImageAttachment, type DraftThreadEnvMode, finalizePromotedDraftThreadByRef, @@ -254,6 +269,7 @@ import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../state/ import { projectEnvironment } from "../state/projects"; import { useEnvironmentQuery } from "../state/query"; import { + environmentServerConfigsAtom, primaryServerAvailableEditorsAtom, primaryServerKeybindingsAtom, primaryServerSettingsAtom, @@ -322,9 +338,16 @@ import { import { resolveDisplayedThreadPr, 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, @@ -351,6 +374,7 @@ import { shouldDockDraftHeroForSubmission, shouldReleaseTimelineAnchorForToolActivity, shouldShowBranchMismatchBanner, + shouldShowPlanFollowUpPrompt, getStartedThreadModelChangeBlockReason, LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, LastInvokedScriptByProjectSchema, @@ -359,6 +383,8 @@ import { cloneComposerImageForRetry, deriveLockedProvider, readFileAsDataUrl, + loadVideoPreviewUrl, + isVideoPreviewRequestCurrent, reconcileMountedTerminalThreadIds, resolveBackgroundDraftWorkspaceOptions, resolveDraftHeroState, @@ -367,6 +393,7 @@ import { revokeBlobPreviewUrl, revokeUserMessagePreviewUrls, shouldWriteThreadErrorToCurrentServerThread, + codexArtifactTemplatePromptToAppend, waitForStartedServerThread, } from "./ChatView.logic"; import type { ThreadSyncPhase } from "../threadSync"; @@ -375,13 +402,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, @@ -393,19 +426,25 @@ 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, serverUpdateGuidance, } from "../versionSkew"; -import { useAssetUrls } from "../assets/assetUrls"; +import { resolveAssetUrl, 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"] = []; @@ -1362,6 +1401,7 @@ function ChatViewContent(props: ChatViewProps) { const draftId = routeKind === "draft" ? props.draftId : null; const threadSyncPhase = routeKind === "server" ? (props.threadSyncPhase ?? null) : null; const threadDetailLoading = threadSyncPhase === "loading"; + const { settleThread, pinThread, confirmAndUnpinThread } = useThreadActions(); const routeThreadRef = useMemo( () => scopeThreadRef(environmentId, threadId), [environmentId, threadId], @@ -1388,6 +1428,9 @@ function ChatViewContent(props: ChatViewProps) { reportFailure: false, }); const startThreadTurn = useAtomCommand(threadEnvironment.startTurn, { reportFailure: false }); + const createAttachmentAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { + reportFailure: false, + }); const uploadThreadFeedback = useAtomCommand(threadEnvironment.uploadFeedback, { reportFailure: false, }); @@ -1470,8 +1513,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, ); @@ -1498,13 +1549,29 @@ 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 videoPreviewAbortControllerRef = useRef(null); + const cancelVideoPreviewRequest = useCallback(() => { + videoPreviewRequestIdRef.current += 1; + videoPreviewAbortControllerRef.current?.abort(); + videoPreviewAbortControllerRef.current = null; + }, []); + 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> @@ -1605,6 +1672,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>({}); @@ -1612,25 +1680,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), ); @@ -2203,6 +2252,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 @@ -2226,6 +2281,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"; @@ -2253,8 +2314,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/ComposerActivityStatus.tsx b/apps/web/src/components/chat/ComposerActivityStatus.tsx new file mode 100644 index 000000000000..49d4bece350d --- /dev/null +++ b/apps/web/src/components/chat/ComposerActivityStatus.tsx @@ -0,0 +1,22 @@ +import { LoaderCircleIcon } from "lucide-react"; +import { threadSyncLabel, type ThreadSyncPhase } from "../../threadSync"; +import { ComposerBanner } from "./ComposerBanner"; + +export function ComposerActivityRow({ phase }: { readonly phase: ThreadSyncPhase }) { + return ( + + + + + + + {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..f81565718c14 --- /dev/null +++ b/apps/web/src/components/chat/ComposerBanner.tsx @@ -0,0 +1,352 @@ +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: "[--chat-composer-attached-outline:color-mix(in_srgb,var(--info)_32%,transparent)] [--chat-composer-attached-tint:color-mix(in_srgb,var(--info)_4%,transparent)]", + 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)]", + 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-info/24", + success: "border-success/24", + 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..7f51dc601d08 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 ? ( -